Compare commits
1 Commits
master
..
85b2016e69
| Author | SHA1 | Date | |
|---|---|---|---|
| 85b2016e69 |
@@ -1,408 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>交互式对话框生成器</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#165DFF',
|
||||
secondary: '#6B7280',
|
||||
accent: '#F59E0B'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style type="text/tailwindcss">
|
||||
@layer utilities {
|
||||
.dialog-item {
|
||||
@apply p-3 border rounded-lg cursor-pointer transition-all hover:bg-primary/5 hover:border-primary;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.gen-dialog {
|
||||
position: absolute;
|
||||
min-width: 140px;
|
||||
max-width: 280px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.gen-dialog:hover { box-shadow: 0 6px 32px rgba(0,0,0,0.18); }
|
||||
.gen-dialog.active-drag { z-index: 20; box-shadow: 0 8px 40px rgba(0,0,0,0.22); }
|
||||
|
||||
/* 样式1:白色圆角 */
|
||||
.gen-dialog.style1 { background: rgba(255,255,255,0.97); border: 2px solid #CBD5E1; }
|
||||
.gen-dialog.style1 .gen-confirm { background: #165DFF; }
|
||||
.gen-dialog.style1 .gen-text { color: #1e293b; }
|
||||
|
||||
/* 样式2:蓝色气泡 */
|
||||
.gen-dialog.style2 { background: rgba(22,93,255,0.95); border: 2px solid #4F8AFF; border-radius: 16px 16px 4px 16px; }
|
||||
.gen-dialog.style2 .gen-confirm { background: #fff; color: #165DFF; }
|
||||
.gen-dialog.style2 .gen-text { color: #fff; }
|
||||
.gen-dialog.style2 .gen-text::placeholder { color: rgba(255,255,255,0.6); }
|
||||
|
||||
/* 样式3:黄色提示 */
|
||||
.gen-dialog.style3 { background: rgba(255,247,237,0.97); border: 2px solid #F59E0B; }
|
||||
.gen-dialog.style3 .gen-confirm { background: #F59E0B; }
|
||||
.gen-dialog.style3 .gen-text { color: #92400e; }
|
||||
.gen-dialog.style3 .gen-text::placeholder { color: rgba(146,64,14,0.4); }
|
||||
|
||||
/* 样式4:灰色简约 */
|
||||
.gen-dialog.style4 { background: rgba(248,250,252,0.97); border: 2px solid #6B7280; border-radius: 4px; }
|
||||
.gen-dialog.style4 .gen-confirm { background: #6B7280; }
|
||||
.gen-dialog.style4 .gen-text { color: #1f2937; }
|
||||
|
||||
.gen-text {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.gen-text::placeholder { color: rgba(0,0,0,0.3); }
|
||||
|
||||
.gen-confirm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.gen-confirm:hover { filter: brightness(1.1); transform: translateY(-1px); }
|
||||
.gen-confirm:active { transform: translateY(0); }
|
||||
|
||||
/* 已确认状态 */
|
||||
.gen-dialog.confirmed .gen-text { cursor: default; }
|
||||
.gen-dialog.confirmed .gen-confirm { display: none; }
|
||||
.gen-dialog.confirmed .gen-edit-hint { display: inline-block; }
|
||||
.gen-dialog:not(.confirmed) .gen-edit-hint { display: none; }
|
||||
|
||||
.gen-edit-hint {
|
||||
font-size: 10px;
|
||||
color: rgba(0,0,0,0.3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 删除按钮 */
|
||||
.gen-delete {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #EF4444;
|
||||
color: #fff;
|
||||
border: 2px solid #fff;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 5;
|
||||
}
|
||||
.gen-dialog:hover .gen-delete { opacity: 1; }
|
||||
|
||||
#previewContainer { position: relative; }
|
||||
#previewImage { width: 100%; height: 100%; background-size: contain; background-position: center; background-repeat: no-repeat; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-center mb-8 text-gray-800">
|
||||
交互式对话框生成器
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-6">
|
||||
<!-- 左侧面板 -->
|
||||
<div class="lg:w-1/3 bg-white rounded-xl shadow-md p-6">
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold mb-4 text-gray-700">
|
||||
<i class="fa fa-upload mr-2 text-primary"></i>上传背景图片
|
||||
</h2>
|
||||
<div id="dropArea" class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary transition-colors">
|
||||
<i class="fa fa-image text-4xl text-gray-400 mb-3"></i>
|
||||
<p class="text-gray-500 mb-2">点击或拖拽图片到此处</p>
|
||||
<p class="text-xs text-gray-400">支持 JPG、PNG、WEBP 格式</p>
|
||||
<input type="file" id="fileInput" accept="image/*" class="hidden">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话框选择区域 -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-4 text-gray-700">
|
||||
<i class="fa fa-comment mr-2 text-primary"></i>点击添加对话框
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mb-3">每点一次即在预览区新增一个对话框</p>
|
||||
<div class="space-y-3" id="dialogList">
|
||||
<div class="dialog-item" data-style="style1">
|
||||
<span class="inline-block w-3 h-3 rounded bg-white border border-gray-300 mr-2 align-middle"></span>白色圆角对话框
|
||||
</div>
|
||||
<div class="dialog-item" data-style="style2">
|
||||
<span class="inline-block w-3 h-3 rounded bg-blue-500 mr-2 align-middle"></span>蓝色气泡对话框
|
||||
</div>
|
||||
<div class="dialog-item" data-style="style3">
|
||||
<span class="inline-block w-3 h-3 rounded bg-amber-400 mr-2 align-middle"></span>黄色提示对话框
|
||||
</div>
|
||||
<div class="dialog-item" data-style="style4">
|
||||
<span class="inline-block w-3 h-3 rounded bg-gray-400 mr-2 align-middle"></span>灰色简约对话框
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button id="clearBtn" class="w-full bg-gray-200 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 transition-all">
|
||||
<i class="fa fa-trash mr-1"></i>清空全部对话框
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧预览面板 -->
|
||||
<div class="lg:w-2/3 bg-white rounded-xl shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 text-gray-700">
|
||||
<i class="fa fa-eye mr-2 text-primary"></i>预览区域
|
||||
</h2>
|
||||
<div id="previewContainer" class="relative w-full h-[500px] border rounded-lg bg-gray-100 overflow-hidden">
|
||||
<div id="previewImage"></div>
|
||||
<div id="dialogContainer" class="absolute inset-0"></div>
|
||||
<div id="emptyTip" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center text-gray-400 pointer-events-none">
|
||||
<i class="fa fa-image text-5xl mb-3"></i>
|
||||
<p>上传图片后开始编辑</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-3">
|
||||
<i class="fa fa-info-circle mr-1"></i>提示:对话框可拖动定位,输入文字后点确认即可渲染,双击已确认的框可重新编辑
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let dialogCount = 0;
|
||||
|
||||
const dropArea = document.getElementById('dropArea');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const previewImage = document.getElementById('previewImage');
|
||||
const emptyTip = document.getElementById('emptyTip');
|
||||
const dialogList = document.getElementById('dialogList');
|
||||
const dialogContainer = document.getElementById('dialogContainer');
|
||||
const clearBtn = document.getElementById('clearBtn');
|
||||
const previewContainer = document.getElementById('previewContainer');
|
||||
|
||||
// ===== 文件上传 =====
|
||||
dropArea.addEventListener('click', () => fileInput.click());
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
|
||||
dropArea.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); });
|
||||
});
|
||||
dropArea.addEventListener('drop', e => handleFile(e.dataTransfer.files[0]));
|
||||
fileInput.addEventListener('change', e => handleFile(e.target.files[0]));
|
||||
|
||||
function handleFile(file) {
|
||||
if (!file || !file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
previewImage.style.backgroundImage = `url(${e.target.result})`;
|
||||
emptyTip.classList.add('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
// ===== 点击添加对话框 =====
|
||||
dialogList.addEventListener('click', e => {
|
||||
const item = e.target.closest('.dialog-item');
|
||||
if (!item) return;
|
||||
createDialog(item.dataset.style);
|
||||
});
|
||||
|
||||
function createDialog(style) {
|
||||
dialogCount++;
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = `gen-dialog ${style}`;
|
||||
dialog.dataset.style = style;
|
||||
|
||||
// 随机偏移避免完全重叠
|
||||
const offsetX = 30 + (dialogCount * 25) % 200;
|
||||
const offsetY = 30 + (dialogCount * 20) % 150;
|
||||
dialog.style.left = offsetX + 'px';
|
||||
dialog.style.top = offsetY + 'px';
|
||||
dialog.style.padding = '12px 14px';
|
||||
|
||||
// 删除按钮
|
||||
const delBtn = document.createElement('div');
|
||||
delBtn.className = 'gen-delete';
|
||||
delBtn.innerHTML = '<i class="fa fa-times" style="font-size:9px"></i>';
|
||||
delBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
dialog.remove();
|
||||
});
|
||||
|
||||
// 文本输入区
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'gen-text';
|
||||
textarea.rows = 2;
|
||||
textarea.placeholder = '输入文本...';
|
||||
// 阻止拖动冲突
|
||||
textarea.addEventListener('mousedown', e => e.stopPropagation());
|
||||
textarea.addEventListener('touchstart', e => e.stopPropagation());
|
||||
|
||||
// 底部:确认按钮 + 编辑提示
|
||||
const bottomRow = document.createElement('div');
|
||||
bottomRow.style.cssText = 'display:flex; justify-content:flex-end; align-items:center;';
|
||||
|
||||
const editHint = document.createElement('span');
|
||||
editHint.className = 'gen-edit-hint';
|
||||
editHint.textContent = '双击编辑';
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.className = 'gen-confirm';
|
||||
confirmBtn.innerHTML = '<i class="fa fa-check" style="font-size:10px"></i> 确认';
|
||||
|
||||
bottomRow.appendChild(editHint);
|
||||
bottomRow.appendChild(confirmBtn);
|
||||
|
||||
dialog.appendChild(delBtn);
|
||||
dialog.appendChild(textarea);
|
||||
dialog.appendChild(bottomRow);
|
||||
dialogContainer.appendChild(dialog);
|
||||
|
||||
// ===== 确认 =====
|
||||
confirmBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const text = textarea.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// 把textarea换成纯文本展示
|
||||
const textDisplay = document.createElement('div');
|
||||
textDisplay.className = 'gen-text';
|
||||
textDisplay.style.whiteSpace = 'pre-wrap';
|
||||
textDisplay.textContent = text;
|
||||
textarea.replaceWith(textDisplay);
|
||||
|
||||
dialog.classList.add('confirmed');
|
||||
});
|
||||
|
||||
// ===== 双击重新编辑 =====
|
||||
dialog.addEventListener('dblclick', e => {
|
||||
if (!dialog.classList.contains('confirmed')) return;
|
||||
e.stopPropagation();
|
||||
|
||||
const textDisplay = dialog.querySelector('.gen-text');
|
||||
const currentText = textDisplay.textContent;
|
||||
|
||||
const newTextarea = document.createElement('textarea');
|
||||
newTextarea.className = 'gen-text';
|
||||
newTextarea.rows = 2;
|
||||
newTextarea.value = currentText;
|
||||
newTextarea.addEventListener('mousedown', e => e.stopPropagation());
|
||||
newTextarea.addEventListener('touchstart', e => e.stopPropagation());
|
||||
|
||||
textDisplay.replaceWith(newTextarea);
|
||||
dialog.classList.remove('confirmed');
|
||||
newTextarea.focus();
|
||||
});
|
||||
|
||||
// ===== 拖动 =====
|
||||
bindDrag(dialog);
|
||||
|
||||
// 自动聚焦输入
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
// ===== 拖动逻辑 =====
|
||||
function bindDrag(element) {
|
||||
let dragging = false, ox, oy;
|
||||
|
||||
element.addEventListener('mousedown', e => {
|
||||
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON' || e.target.closest('.gen-delete') || e.target.closest('.gen-confirm')) return;
|
||||
dragging = true;
|
||||
element.classList.add('active-drag');
|
||||
const rect = element.getBoundingClientRect();
|
||||
ox = e.clientX - rect.left;
|
||||
oy = e.clientY - rect.top;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', e => {
|
||||
if (!dragging) return;
|
||||
const cr = dialogContainer.getBoundingClientRect();
|
||||
let x = e.clientX - ox - cr.left;
|
||||
let y = e.clientY - oy - cr.top;
|
||||
x = Math.max(0, Math.min(x, cr.width - element.offsetWidth));
|
||||
y = Math.max(0, Math.min(y, cr.height - element.offsetHeight));
|
||||
element.style.left = x + 'px';
|
||||
element.style.top = y + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (dragging) {
|
||||
dragging = false;
|
||||
element.classList.remove('active-drag');
|
||||
}
|
||||
});
|
||||
|
||||
// 触摸支持
|
||||
element.addEventListener('touchstart', e => {
|
||||
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON' || e.target.closest('.gen-delete') || e.target.closest('.gen-confirm')) return;
|
||||
dragging = true;
|
||||
element.classList.add('active-drag');
|
||||
const rect = element.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
ox = touch.clientX - rect.left;
|
||||
oy = touch.clientY - rect.top;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!dragging) return;
|
||||
const touch = e.touches[0];
|
||||
const cr = dialogContainer.getBoundingClientRect();
|
||||
let x = touch.clientX - ox - cr.left;
|
||||
let y = touch.clientY - oy - cr.top;
|
||||
x = Math.max(0, Math.min(x, cr.width - element.offsetWidth));
|
||||
y = Math.max(0, Math.min(y, cr.height - element.offsetHeight));
|
||||
element.style.left = x + 'px';
|
||||
element.style.top = y + 'px';
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
if (dragging) {
|
||||
dragging = false;
|
||||
element.classList.remove('active-drag');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 清空 =====
|
||||
clearBtn.addEventListener('click', () => {
|
||||
dialogContainer.innerHTML = '';
|
||||
dialogCount = 0;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,8 @@
|
||||
# Frontend environment variables are intentionally unsupported.
|
||||
#
|
||||
# API traffic must go through same-origin /api.
|
||||
# Public runtime settings must come from application APIs.
|
||||
# Provider keys and OSS credentials must stay on the server.
|
||||
# Dev proxy target — the backend API server
|
||||
VITE_DEV_PROXY=http://47.110.225.76:3600
|
||||
|
||||
# Key server URL for auth/profile endpoints
|
||||
VITE_KEY_SERVER_URL=
|
||||
|
||||
# Main API base URL (used when not served from omniai.net.cn)
|
||||
VITE_API_BASE_URL=
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Web Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- "codex/**"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Verify
|
||||
run: npm run verify
|
||||
@@ -10,8 +10,6 @@ node_modules/
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
tmp/
|
||||
*.swp
|
||||
*.swo
|
||||
coverage/
|
||||
coverage/
|
||||
@@ -1,10 +0,0 @@
|
||||
dist
|
||||
node_modules
|
||||
coverage
|
||||
tmp
|
||||
.codex-tmp
|
||||
.codex-logs
|
||||
screenshots
|
||||
*.log
|
||||
*.tmp
|
||||
package-lock.json
|
||||
@@ -1,39 +0,0 @@
|
||||
# Project Rules
|
||||
|
||||
## Asset, Key, And Runtime Data Governance
|
||||
|
||||
These rules are mandatory for all frontend, backend, deployment, and agent-generated changes.
|
||||
|
||||
1. Image and media assets must be stored in OSS.
|
||||
- Do not commit product images, demo images, generated images, videos, or other large media assets into `src/assets` or other source folders.
|
||||
- Code may reference media only by OSS URL or by data returned from an API.
|
||||
- Local assets are limited to tiny build-critical files such as icons or placeholders, and require explicit justification.
|
||||
|
||||
2. Frontend code must not contain API keys or secrets.
|
||||
- Do not hard-code provider keys, access keys, tokens, private endpoints, passwords, or bearer tokens in TypeScript, CSS, HTML, Vite config, Nginx snippets, or checked-in docs.
|
||||
- Browser-delivered code must treat every visible value as public.
|
||||
|
||||
3. Provider keys are owned by the server key pool.
|
||||
- AI provider credentials are stored and managed server-side.
|
||||
- The frontend requests work through application APIs; the server leases provider keys from the concurrency/key pool and calls providers on behalf of the client.
|
||||
- Do not add direct browser-to-provider calls that require provider credentials.
|
||||
|
||||
4. Application data must come through APIs.
|
||||
- Do not hard-code product data, pricing, model availability, provider routing, account state, usage state, or operational configuration in the frontend.
|
||||
- Use typed API clients and server-provided payloads for runtime data.
|
||||
- Static constants are allowed only for presentation defaults that are not business-authoritative.
|
||||
|
||||
5. Do not use fixed environment configuration in application code.
|
||||
- Do not bake production hostnames, provider endpoints, keys, or environment-specific behavior into source code.
|
||||
- Environment-specific values belong in server deployment configuration, secret management, or runtime configuration endpoints.
|
||||
- Frontend code must not add fixed `VITE_*` or equivalent environment variables for API hosts, provider hosts, business data, or secrets.
|
||||
- If the browser needs runtime configuration, it must request that data from an application API.
|
||||
|
||||
6. Deployment configuration must follow the same rules.
|
||||
- Nginx and process manager configs must not embed provider API keys or long-lived credentials.
|
||||
- Reverse proxies should route application traffic to the backend, not expose third-party credentials.
|
||||
- Secrets must be rotated immediately if found in source, Git remotes, shell history, Nginx config, process manager config, or logs.
|
||||
|
||||
7. Reviews must reject violations.
|
||||
- Any new local media file, hard-coded key, direct provider credential path, or fixed production config is a blocking issue.
|
||||
- Prefer deleting local assets and replacing them with OSS URLs returned by APIs or server-managed config.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Optimization Backlog
|
||||
|
||||
## Progress Contract Frontend Consumption
|
||||
|
||||
- Status: pending
|
||||
- Priority: medium
|
||||
- Context: The backend now returns `progressSource`, `stage`, `startedAt`, and `expectedDurationMs` on generation task status payloads. The frontend progress UI currently still derives these values locally from message state and static defaults.
|
||||
- Follow-up: Wire the backend task progress contract through `aiGenerationClient`, task/message view models, and the progress card components so model-aware `expectedDurationMs` and real provider progress can be consumed end to end.
|
||||
- Boundary: Keep this separate from the task store consolidation. The store consolidation is complete without requiring these fields because `WebGenerationPreviewTask` is not the source for Workbench progress cards.
|
||||
@@ -1,77 +0,0 @@
|
||||
import js from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"coverage/**",
|
||||
"tmp/**",
|
||||
".codex-tmp/**",
|
||||
".codex-logs/**",
|
||||
"screenshots/**",
|
||||
"*.log",
|
||||
"*.tmp",
|
||||
"vite-*.log",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"scripts/**/*.mjs",
|
||||
"vite.config.ts",
|
||||
"eslint.config.js",
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
AbortController: "readonly",
|
||||
AbortSignal: "readonly",
|
||||
Blob: "readonly",
|
||||
clearInterval: "readonly",
|
||||
clearTimeout: "readonly",
|
||||
console: "readonly",
|
||||
crypto: "readonly",
|
||||
document: "readonly",
|
||||
File: "readonly",
|
||||
fetch: "readonly",
|
||||
FormData: "readonly",
|
||||
Headers: "readonly",
|
||||
HTMLTextAreaElement: "readonly",
|
||||
localStorage: "readonly",
|
||||
navigator: "readonly",
|
||||
process: "readonly",
|
||||
React: "readonly",
|
||||
RequestInit: "readonly",
|
||||
Response: "readonly",
|
||||
setInterval: "readonly",
|
||||
setTimeout: "readonly",
|
||||
URL: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
window: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-unused-expressions": "warn",
|
||||
"no-empty": ["error", { allowEmptyCatch: true }],
|
||||
"no-undef": "off",
|
||||
"no-useless-escape": "warn",
|
||||
"prefer-const": "warn",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
Before Width: | Height: | Size: 729 KiB |
@@ -7,12 +7,7 @@
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"test": "node scripts/run-unit-tests.mjs",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint .",
|
||||
"format:check": "prettier --check .github/workflows/web-quality.yml eslint.config.js scripts/run-unit-tests.mjs src/test src/api/generationConcurrency.test.ts src/utils/enterpriseVideoPolicy.test.ts src/utils/taskLifecycle.test.ts",
|
||||
"verify": "npm run test && npm run type-check && npm run lint && npm run format:check && npm run governance:check && npm run style:check && npm run build",
|
||||
"governance:check": "node scripts/check-governance.mjs",
|
||||
"style:check": "node scripts/check-style-governance.mjs",
|
||||
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
|
||||
},
|
||||
@@ -24,18 +19,13 @@
|
||||
"zustand": "5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "18.2.0",
|
||||
"@types/react-dom": "18.2.0",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"playwright": "1.60.0",
|
||||
"prettier": "^3.8.3",
|
||||
"sharp": "0.34.5",
|
||||
"typescript": "5.3.3",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "5.4.21",
|
||||
"vite": "5.1.0",
|
||||
"vite-plugin-compression2": "2.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 512 KiB |
|
Before Width: | Height: | Size: 525 KiB |
|
Before Width: | Height: | Size: 674 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 685 KiB |
@@ -1,80 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const mediaExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".mov", ".webm", ".avif"]);
|
||||
const textExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".html", ".css", ".md", ".env", ".example"]);
|
||||
|
||||
const scanRoots = ["src", "vite.config.ts", "index.html", "package.json", ".env.example"];
|
||||
const allowedFiles = new Set([
|
||||
normalizePath("src/data/ossAssets.ts"),
|
||||
normalizePath("src/utils/ossImageOptimize.ts"),
|
||||
]);
|
||||
|
||||
const forbiddenPatterns = [
|
||||
{ label: "frontend env config", pattern: /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/ },
|
||||
{ label: "direct provider proxy", pattern: /\/dashscope-api\b|dashscope\.aliyuncs\.com/i },
|
||||
{ label: "third-party demo media host", pattern: /picsum\.photos|xiuxiu-pro(?:-new)?\.meitudata\.com|meitudata\.com/i },
|
||||
{ label: "hard-coded provider secret marker", pattern: /Bearer\s+sk-|DASHSCOPE_API_KEY|ACCESS_KEY_SECRET|SECRET_ACCESS_KEY/i },
|
||||
{ label: "local media import", pattern: /from\s+["'][^"']*\/assets\/[^"']*\.(?:png|jpe?g|webp|gif|mp4|mov|webm|avif|svg)["']/i },
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
function normalizePath(value) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function walk(targetPath, visitor) {
|
||||
if (!fs.existsSync(targetPath)) return;
|
||||
const stat = fs.statSync(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
for (const entry of fs.readdirSync(targetPath)) {
|
||||
if (entry === "node_modules" || entry === "dist" || entry === ".git") continue;
|
||||
walk(path.join(targetPath, entry), visitor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
visitor(targetPath, stat);
|
||||
}
|
||||
|
||||
function report(file, message) {
|
||||
failures.push(`${normalizePath(path.relative(repoRoot, file))}: ${message}`);
|
||||
}
|
||||
|
||||
walk(path.join(repoRoot, "src", "assets"), (file) => {
|
||||
if (mediaExtensions.has(path.extname(file).toLowerCase())) {
|
||||
report(file, "media files must live in OSS, not src/assets");
|
||||
}
|
||||
});
|
||||
|
||||
for (const root of scanRoots) {
|
||||
walk(path.join(repoRoot, root), (file) => {
|
||||
const relative = normalizePath(path.relative(repoRoot, file));
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
if (!textExtensions.has(ext) && !relative.endsWith(".env.example")) return;
|
||||
if (relative.startsWith("src/assets/")) return;
|
||||
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
const isAllowed = allowedFiles.has(relative);
|
||||
for (const rule of forbiddenPatterns) {
|
||||
if (isAllowed && (rule.label === "third-party demo media host" || rule.label === "hard-coded provider secret marker")) {
|
||||
continue;
|
||||
}
|
||||
if (rule.pattern.test(content)) {
|
||||
report(file, `forbidden ${rule.label}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("Governance check failed:");
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Governance check passed.");
|
||||
@@ -1 +0,0 @@
|
||||
import "./check-governance.mjs";
|
||||
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* 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]
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of importMap.keys()) {
|
||||
findCircular(file);
|
||||
}
|
||||
|
||||
// Check high-fanin files (imported by many)
|
||||
const fanIn = new Map();
|
||||
for (const imports of importMap.values()) {
|
||||
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 imports of importMap.values()) {
|
||||
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`);
|
||||
}
|
||||
if (emptyDeps > 0) {
|
||||
console.log(` [MOUNT-EFFECT] ${r.file}: ${emptyDeps} useEffect(s) run on mount only`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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('═══════════════════════════════════════════════');
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* Dynamic performance analysis using Playwright.
|
||||
* Measures: page load, bundle sizes, memory, rendering, network.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { 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;
|
||||
const 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('╚═══════════════════════════════════════════════╝');
|
||||
|
||||
analyzeBundles();
|
||||
await runtimeAnalysis();
|
||||
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' ANALYSIS COMPLETE');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
@@ -1,55 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { createServer } from "vite";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function normalizePath(value) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function findTestFiles(dir) {
|
||||
const result = [];
|
||||
if (!fs.existsSync(dir)) return result;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...findTestFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
if (/\.test\.tsx?$/.test(entry.name)) result.push(fullPath);
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
configFile: path.join(repoRoot, "vite.config.ts"),
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
|
||||
try {
|
||||
const harness = await server.ssrLoadModule("/src/test/testHarness");
|
||||
const testFiles = findTestFiles(path.join(repoRoot, "src"));
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
console.error("No test files found.");
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(`Running ${testFiles.length} test files`);
|
||||
|
||||
for (const file of testFiles) {
|
||||
const modulePath = `/${normalizePath(path.relative(repoRoot, file))}`;
|
||||
await server.ssrLoadModule(modulePath);
|
||||
}
|
||||
|
||||
const result = await harness.runRegisteredTests();
|
||||
console.log(`Unit test result: ${result.passed}/${result.total} passed`);
|
||||
if (result.failed > 0) process.exitCode = 1;
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const failures = [];
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertMatch(label, content, pattern) {
|
||||
if (!pattern.test(content)) {
|
||||
failures.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoMatch(label, content, pattern) {
|
||||
if (pattern.test(content)) {
|
||||
failures.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
const serverConnection = read("src/api/serverConnection.ts");
|
||||
const generationClient = read("src/api/aiGenerationClient.ts");
|
||||
const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts");
|
||||
const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts");
|
||||
|
||||
assertMatch(
|
||||
"serverConnection must build same-origin /api URLs",
|
||||
serverConnection,
|
||||
/return\s+`\/api\/\$\{cleanPath\}`;/,
|
||||
);
|
||||
assertNoMatch(
|
||||
"frontend generation flow must not use fixed VITE environment config",
|
||||
`${serverConnection}\n${generationClient}`,
|
||||
/\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/,
|
||||
);
|
||||
assertNoMatch(
|
||||
"frontend generation flow must not call provider hosts directly",
|
||||
generationClient,
|
||||
/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, /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, /serverRequest<\{ url: string; signedUrl\?: string; ossKey\?: string \}>\("oss\/upload-by-url"/);
|
||||
assertMatch(
|
||||
"ecommerce video history must durable-copy media before saving",
|
||||
ecommerceVideoService,
|
||||
/buildDurableVideoHistoryPayload\(payload\)/,
|
||||
);
|
||||
assertMatch(
|
||||
"ecommerce video history must filter temporary provider URLs on read",
|
||||
ecommerceVideoService,
|
||||
/items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/,
|
||||
);
|
||||
assertMatch(
|
||||
"workbench results must persist generated media through OSS",
|
||||
workbenchPersistence,
|
||||
/uploadAssetByUrl\(/,
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error("Mocked generation smoke check failed:");
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Mocked generation smoke check passed.");
|
||||
@@ -1,147 +0,0 @@
|
||||
import { readdirSync, readFileSync } 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');
|
||||
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}`);
|
||||
@@ -1,5 +1,20 @@
|
||||
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";
|
||||
@@ -8,13 +23,11 @@ import ToastContainer from "./components/toast/ToastContainer";
|
||||
import { toast } from "./components/toast/toastStore";
|
||||
import { aiGenerationClient } from "./api/aiGenerationClient";
|
||||
import { keyServerClient } from "./api/keyServerClient";
|
||||
import { setUserMaxConcurrency } from "./api/generationConcurrency";
|
||||
import { notificationClient } from "./api/notificationClient";
|
||||
import {
|
||||
SERVER_SESSION_REPLACED_EVENT,
|
||||
SERVER_SESSION_EXPIRED_EVENT,
|
||||
checkServerHealth,
|
||||
clearAllUserStorage,
|
||||
getErrorMessage,
|
||||
type ServerSessionReplacedDetail,
|
||||
} from "./api/serverConnection";
|
||||
@@ -22,7 +35,6 @@ 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";
|
||||
@@ -33,12 +45,9 @@ const CharacterMixPage = lazy(() => import("./features/character-mix/CharacterMi
|
||||
const CommunityPage = lazy(() => import("./features/community/CommunityPage"));
|
||||
const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage"));
|
||||
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
|
||||
const BetaApplicationsPage = lazy(() => import("./features/beta-applications/BetaApplicationsPage"));
|
||||
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
|
||||
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
|
||||
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
|
||||
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"));
|
||||
@@ -49,7 +58,6 @@ 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";
|
||||
@@ -95,22 +103,18 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
||||
"assets",
|
||||
"ecommerceHub",
|
||||
"ecommerce",
|
||||
"ecommerceTemplates",
|
||||
"sizeTemplate",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"imageWorkbench",
|
||||
"resolutionUpscale",
|
||||
"watermarkRemoval",
|
||||
"subtitleRemoval",
|
||||
"dialogGenerator",
|
||||
"digitalHuman",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
"more",
|
||||
"communityReview",
|
||||
"communityCaseAdd",
|
||||
"betaApplications",
|
||||
"report",
|
||||
"providerHealth",
|
||||
"userAgreement",
|
||||
@@ -118,31 +122,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
||||
"not-found",
|
||||
]);
|
||||
|
||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
|
||||
const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
|
||||
"login",
|
||||
"workbench",
|
||||
"canvas",
|
||||
"community",
|
||||
"communityReview",
|
||||
"communityCaseAdd",
|
||||
"betaApplications",
|
||||
"assets",
|
||||
"ecommerce",
|
||||
"ecommerceHub",
|
||||
"ecommerceTemplates",
|
||||
"sizeTemplate",
|
||||
"digitalHuman",
|
||||
"characterMix",
|
||||
"more",
|
||||
]);
|
||||
|
||||
let legacyPageStylesPromise: Promise<unknown> | null = null;
|
||||
|
||||
function loadLegacyPageStyles(): Promise<unknown> {
|
||||
legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css");
|
||||
return legacyPageStylesPromise;
|
||||
}
|
||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
|
||||
|
||||
function normalizeViewKey(rawView: string): WebViewKey {
|
||||
const normalized =
|
||||
@@ -150,8 +130,6 @@ 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"
|
||||
@@ -160,16 +138,12 @@ function normalizeViewKey(rawView: string): WebViewKey {
|
||||
? "communityReview"
|
||||
: rawView === "community-case-add"
|
||||
? "communityCaseAdd"
|
||||
: rawView === "beta-applications" || rawView === "beta-application-review"
|
||||
? "betaApplications"
|
||||
: rawView;
|
||||
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
|
||||
}
|
||||
|
||||
function readViewFromHash(): WebViewKey {
|
||||
const raw = window.location.hash.replace(/^#\/?/, "");
|
||||
if (!raw) return "home";
|
||||
return normalizeViewKey(raw);
|
||||
return normalizeViewKey(window.location.hash.replace(/^#\/?/, ""));
|
||||
}
|
||||
|
||||
function isWorkspaceView(view: WebViewKey): boolean {
|
||||
@@ -204,7 +178,7 @@ function createWorkflowFromResult(payload: WorkbenchResultActionPayload): WebCan
|
||||
description: payload.prompt || "从生成结果进入画布继续创作。",
|
||||
source: "blank",
|
||||
settings: {
|
||||
model: payload.resultType === "video" ? "Seedance 2.0" : "omni-水果 Pro",
|
||||
model: payload.resultType === "video" ? "Seedance 2.0" : "Nano Banana Pro",
|
||||
ratio: payload.resultType === "video" ? "16:9" : "1:1",
|
||||
duration: payload.resultType === "video" ? "6s" : "0s",
|
||||
resolution: payload.resultType === "video" ? "720p" : "2K",
|
||||
@@ -254,135 +228,68 @@ function App() {
|
||||
const canvasAutoOpenedRecentRef = useRef(false);
|
||||
|
||||
// Session store
|
||||
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,
|
||||
})));
|
||||
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);
|
||||
|
||||
// Project store
|
||||
const {
|
||||
projects,
|
||||
projectsLoaded,
|
||||
canvasWorkflow,
|
||||
currentCanvasProjectId,
|
||||
pendingDeleteProject,
|
||||
deleteProjectSubmitting,
|
||||
setProjects,
|
||||
setProjectsLoaded,
|
||||
setCanvasWorkflow,
|
||||
setCurrentCanvasProjectId,
|
||||
openDeleteProject: openDeleteProjectModal,
|
||||
closeDeleteProject: closeDeleteProjectModal,
|
||||
setDeleteProjectSubmitting,
|
||||
} = 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,
|
||||
})));
|
||||
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);
|
||||
|
||||
// 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,
|
||||
})));
|
||||
const tasks = useTaskStore((s) => s.tasks);
|
||||
const appendTask = useTaskStore((s) => s.appendTask);
|
||||
const mergeServerTasks = useTaskStore((s) => s.mergeServerTasks);
|
||||
const clearTasks = useTaskStore((s) => s.clearTasks);
|
||||
|
||||
// App store
|
||||
const {
|
||||
usage,
|
||||
runtimeNotifications,
|
||||
serverNotifications,
|
||||
activeView,
|
||||
workspaceExpanded,
|
||||
imageWorkbenchTool,
|
||||
pendingEcommerceTemplate,
|
||||
backendHealth,
|
||||
setUsage,
|
||||
pushNotification,
|
||||
setRuntimeNotifications,
|
||||
setServerNotifications,
|
||||
setView,
|
||||
setWorkspaceExpanded,
|
||||
setImageWorkbenchTool,
|
||||
setPendingEcommerceTemplate,
|
||||
setBackendHealth,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
} = 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,
|
||||
})));
|
||||
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 [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
|
||||
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
|
||||
const [onboardingActive, setOnboardingActive] = useState(false);
|
||||
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
|
||||
useEffect(() => {
|
||||
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
|
||||
}, [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");
|
||||
@@ -434,32 +341,29 @@ function App() {
|
||||
|
||||
const navItems = useMemo<WebNavItem[]>(
|
||||
() => [
|
||||
{ key: "home", label: "首页", hint: "项目入口", icon: <ShellIcon name="home" /> },
|
||||
{ key: "workbench", label: "生成", hint: "对话生成页面", icon: <ShellIcon name="robot" /> },
|
||||
{ key: "home", label: "首页", hint: "项目入口", icon: <HomeOutlined /> },
|
||||
{ key: "workbench", label: "生成", hint: "对话生成页面", icon: <RobotOutlined /> },
|
||||
{
|
||||
key: "ecommerce",
|
||||
label: "电商生成",
|
||||
hint: "AI创作与海报生成",
|
||||
icon: <ShellIcon name="shopping" />,
|
||||
icon: <ShoppingOutlined />,
|
||||
},
|
||||
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <ShellIcon name="branches" /> },
|
||||
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <ShellIcon name="global" /> },
|
||||
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <ShellIcon name="bar-chart" /> },
|
||||
{ key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: <ShellIcon name="wallet" /> },
|
||||
{ key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: <ShellIcon name="heart" /> },
|
||||
{ key: "assets", label: "资产库", hint: "角色、场景、道具", icon: <ShellIcon name="folder" /> },
|
||||
{ key: "agent", label: "Agent", hint: "拆解与规划", icon: <ShellIcon name="robot" /> },
|
||||
{ key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: <ShellIcon name="customer-service" /> },
|
||||
{ key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: <ShellIcon name="swap" /> },
|
||||
{ key: "more", label: "工具盒", hint: "图像与镜头工具", icon: <ShellIcon name="tool" /> },
|
||||
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <BranchesOutlined /> },
|
||||
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <GlobalOutlined /> },
|
||||
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <BarChartOutlined /> },
|
||||
{ key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: <WalletOutlined /> },
|
||||
{ key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: <HeartOutlined /> },
|
||||
{ key: "assets", label: "资产库", hint: "角色、场景、道具", icon: <FolderOpenOutlined /> },
|
||||
{ key: "agent", label: "Agent", hint: "拆解与规划", icon: <RobotOutlined /> },
|
||||
{ key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: <CustomerServiceOutlined /> },
|
||||
{ key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: <SwapOutlined /> },
|
||||
{ key: "more", label: "工具盒", hint: "图像与镜头工具", icon: <ToolOutlined /> },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetView = useCallback((view: WebViewKey) => {
|
||||
if (view === "workbench" && Boolean(session)) {
|
||||
setWorkbenchResetToken((token) => token + 1);
|
||||
}
|
||||
window.location.hash = `/${view}`;
|
||||
setView(view);
|
||||
if (view !== "login") {
|
||||
@@ -468,23 +372,11 @@ function App() {
|
||||
if (isWorkspaceView(view)) {
|
||||
setWorkspaceExpanded(true);
|
||||
}
|
||||
}, [session, setView, setWorkspaceExpanded]);
|
||||
|
||||
const handleStartOnboarding = useCallback(() => {
|
||||
setOnboardingActive(true);
|
||||
try { window.localStorage.setItem("omniai:onboarding", "1"); } catch {}
|
||||
handleSetView("workbench");
|
||||
}, [handleSetView]);
|
||||
|
||||
const handleEndOnboarding = useCallback(() => {
|
||||
setOnboardingActive(false);
|
||||
try { window.localStorage.removeItem("omniai:onboarding"); } catch {}
|
||||
}, []);
|
||||
}, [setView, setWorkspaceExpanded]);
|
||||
|
||||
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
|
||||
clearAllUserStorage();
|
||||
keyServerClient.clearSession();
|
||||
clearSessionState();
|
||||
setUserMaxConcurrency(null);
|
||||
setProjects([]);
|
||||
setProjectsLoaded(true);
|
||||
setUsage(emptyUsageSummary);
|
||||
@@ -593,7 +485,6 @@ function App() {
|
||||
const nextSession = await keyServerClient.getCurrentSession();
|
||||
if (cancelled) return;
|
||||
setSession(nextSession);
|
||||
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
||||
await hydrateAccountData(nextSession);
|
||||
};
|
||||
|
||||
@@ -626,7 +517,6 @@ function App() {
|
||||
if (cancelled) return;
|
||||
if (nextSession) {
|
||||
setSession(nextSession);
|
||||
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
||||
} else {
|
||||
clearAuthenticatedState({ resetView: true });
|
||||
}
|
||||
@@ -964,7 +854,6 @@ function App() {
|
||||
async (nextSession: WebUserSession) => {
|
||||
hideSessionReplaced();
|
||||
setSession(nextSession);
|
||||
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
||||
await hydrateAccountData(nextSession);
|
||||
|
||||
if (nextSession.user.email && !nextSession.user.emailVerified) {
|
||||
@@ -1028,7 +917,7 @@ function App() {
|
||||
previewUrl: payload.resultUrl,
|
||||
params: payload.resultType === "video"
|
||||
? { model: "Kling V3 Omni", aspectRatio: "16:9", resolution: "720p", duration: "6s", videoMode: "text-to-video" }
|
||||
: { model: "omni-水果 Pro", aspectRatio: "1:1", imageSize: "2K" },
|
||||
: { model: "Nano Banana Pro", aspectRatio: "1:1", imageSize: "2K" },
|
||||
assetRef: payload.resultOssKey ? { url: payload.resultUrl, ossKey: payload.resultOssKey, mediaType: payload.resultType === "video" ? "video/mp4" : "image/png", sourceTaskId: payload.taskId } : undefined,
|
||||
},
|
||||
];
|
||||
@@ -1100,6 +989,28 @@ function App() {
|
||||
[handleSetView, setImageWorkbenchTool],
|
||||
);
|
||||
|
||||
const renderAdminOnlyPage = useCallback(
|
||||
(content: React.ReactNode) => {
|
||||
if (isAdminAccount(session)) return content;
|
||||
|
||||
return (
|
||||
<div className="feature-access-gate">
|
||||
<div className="feature-access-gate__content" aria-hidden="true">
|
||||
{content}
|
||||
</div>
|
||||
<div className="feature-access-gate__overlay" role="dialog" aria-modal="true" aria-labelledby="feature-access-title">
|
||||
<section className="feature-access-gate__panel panel-surface">
|
||||
<span className="feature-access-gate__eyebrow">功能内测中</span>
|
||||
<h2 id="feature-access-title">暂未开放</h2>
|
||||
<p>敬请期待,该功能还在开发中。</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const PUBLIC_VIEWS = PUBLIC_VIEW_SET;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1126,8 +1037,6 @@ function App() {
|
||||
onOpenWorkbench={() => handleSetView("workbench")}
|
||||
onOpenCommunity={() => handleSetView("community")}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onOpenProject={handleOpenProject}
|
||||
onRemoveWork={(task) => setTasks(tasks.filter((item) => item.id !== task.id))}
|
||||
/>
|
||||
);
|
||||
case "community":
|
||||
@@ -1173,30 +1082,6 @@ function App() {
|
||||
case "ecommerce":
|
||||
case "ecommerceHub":
|
||||
return null;
|
||||
case "ecommerceTemplates":
|
||||
return (
|
||||
<EcommerceTemplatesPage
|
||||
projects={projects}
|
||||
onOpenMore={() => handleSetView("more")}
|
||||
onOpenEcommerce={() => handleSetView("ecommerce")}
|
||||
onSelectTemplate={(template) => {
|
||||
setPendingEcommerceTemplate(template);
|
||||
handleSetView("ecommerce");
|
||||
}}
|
||||
onStartCreate={handleStartTemplateCanvasCreate}
|
||||
onOpenProject={handleOpenProject}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
/>
|
||||
);
|
||||
case "sizeTemplate":
|
||||
return (
|
||||
<SizeTemplatePage
|
||||
isAuthenticated={Boolean(session)}
|
||||
onOpenMore={() => handleSetView("more")}
|
||||
onOpenEcommerce={() => handleSetView("ecommerce")}
|
||||
onSelectView={handleSetView}
|
||||
/>
|
||||
);
|
||||
case "digitalHuman":
|
||||
return (
|
||||
<DigitalHumanPage
|
||||
@@ -1271,8 +1156,6 @@ function App() {
|
||||
onSelectView={handleSetView}
|
||||
/>
|
||||
);
|
||||
case "dialogGenerator":
|
||||
return <DialogGeneratorPage />;
|
||||
case "report":
|
||||
return <ReportPage />;
|
||||
case "providerHealth":
|
||||
@@ -1298,27 +1181,20 @@ function App() {
|
||||
onOpenReview={() => handleSetView("communityReview")}
|
||||
/>
|
||||
);
|
||||
case "betaApplications":
|
||||
return <BetaApplicationsPage session={session} onOpenLogin={handleOpenLogin} />;
|
||||
case "workbench":
|
||||
return (
|
||||
<WorkbenchPage
|
||||
key={`workbench-${workbenchResetToken}`}
|
||||
isAuthenticated={Boolean(session)}
|
||||
session={session}
|
||||
onboarding={onboardingActive}
|
||||
onEndOnboarding={handleEndOnboarding}
|
||||
onRequireLogin={handleRequireTaskLogin}
|
||||
onOpenResultInCanvas={handleOpenResultInCanvas}
|
||||
onRefreshUsage={refreshUsage}
|
||||
resetToken={workbenchResetToken}
|
||||
/>
|
||||
);
|
||||
case "home":
|
||||
return (
|
||||
<HomePage
|
||||
onOpenGenerate={() => handleSetView("workbench")}
|
||||
onStartOnboarding={handleStartOnboarding}
|
||||
onOpenCanvas={() => handleSetView("canvas")}
|
||||
onOpenEcommerce={() => handleSetView("ecommerce")}
|
||||
onOpenScriptReview={() => handleSetView("scriptTokens")}
|
||||
@@ -1348,7 +1224,7 @@ function App() {
|
||||
onMarkNotificationRead={handleMarkNotificationRead}
|
||||
onMarkAllNotificationsRead={handleMarkAllNotificationsRead}
|
||||
>
|
||||
<ErrorBoundary key={activeView}>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={
|
||||
<div className="page-loading-center">
|
||||
<div className="page-loading-spinner" />
|
||||
@@ -1358,13 +1234,10 @@ function App() {
|
||||
<PageTransition viewKey={activeView}>
|
||||
{activePage}
|
||||
</PageTransition>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */}
|
||||
{ecommerceEverMounted && (
|
||||
<div className="keepalive-ecommerce" style={{ display: isEcommerceActive ? undefined : "none" }}>
|
||||
<Suspense fallback={null}>
|
||||
{/* KeepAlive: EcommercePage stays mounted once visited */}
|
||||
{ecommerceEverMounted && (
|
||||
<div style={{ display: isEcommerceActive ? undefined : "none", position: "absolute", inset: 0, zIndex: 1 }}>
|
||||
<EcommercePage
|
||||
projects={projects}
|
||||
isAuthenticated={Boolean(session)}
|
||||
@@ -1377,9 +1250,10 @@ function App() {
|
||||
initialTemplate={pendingEcommerceTemplate}
|
||||
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
{loginPromptOpen && pendingAction ? (
|
||||
<div className="login-gate-modal" role="dialog" aria-modal="true" aria-labelledby="login-gate-title">
|
||||
@@ -1453,7 +1327,7 @@ function App() {
|
||||
/>
|
||||
<section className="project-delete-modal__panel">
|
||||
<span className="project-delete-modal__icon">
|
||||
<ShellIcon name="delete" />
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
<h2 id="project-delete-title">删除项目</h2>
|
||||
<p>确认删除项目「{pendingDeleteProject.name}」?删除后将从服务器项目列表移除。</p>
|
||||
|
||||
@@ -3,19 +3,6 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
||||
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
||||
|
||||
function combineAbortSignals(signal: AbortSignal | undefined, timeoutSignal: AbortSignal): AbortSignal {
|
||||
if (!signal) return timeoutSignal;
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
if (signal.aborted || timeoutSignal.aborted) {
|
||||
abort();
|
||||
return controller.signal;
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
timeoutSignal.addEventListener("abort", abort, { once: true });
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
export interface AdVideoUserConfig {
|
||||
platform: string;
|
||||
aspectRatio: string;
|
||||
@@ -175,7 +162,9 @@ async function chat(
|
||||
{ role: "user", content: userContent },
|
||||
];
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
|
||||
const combinedSignal = options?.signal
|
||||
? AbortSignal.any([options.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
@@ -221,7 +210,9 @@ async function visionChat(
|
||||
let lastError: Error | null = null;
|
||||
for (const model of VISION_MODELS) {
|
||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
|
||||
const combinedSignal = signal
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
try {
|
||||
const out = await retryOnTransient(async () => {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
buildAuthHeaders,
|
||||
isRecord,
|
||||
readJsonResponse,
|
||||
serverRequest,
|
||||
isServerRequestError,
|
||||
throwResponseError,
|
||||
} from "./serverConnection";
|
||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||
@@ -136,12 +134,6 @@ export interface ChatInput {
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface ChatUsage {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
export interface AiTaskStatus {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
@@ -150,10 +142,6 @@ export interface AiTaskStatus {
|
||||
type: "image" | "video";
|
||||
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
progressSource?: "real" | "estimated" | string | null;
|
||||
stage?: string | null;
|
||||
startedAt?: string | null;
|
||||
expectedDurationMs?: number | null;
|
||||
resultUrl: string | null;
|
||||
error: string | null;
|
||||
params?: Record<string, unknown>;
|
||||
@@ -171,7 +159,7 @@ function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPrevi
|
||||
function taskTitle(task: AiTaskStatus): string {
|
||||
const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : "";
|
||||
if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt;
|
||||
return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1";
|
||||
return task.type === "video" ? "视频生成任务" : "图像生成任务";
|
||||
}
|
||||
|
||||
function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask {
|
||||
@@ -249,50 +237,6 @@ function emitImageRouteDebug(label: string, payload: Record<string, unknown>): v
|
||||
|
||||
let taskHistoryRouteMissing = false;
|
||||
|
||||
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
|
||||
const TASK_STATUS_TIMEOUT_MS = 20_000;
|
||||
const NON_RETRYING_REQUEST = { maxRetries: 0 };
|
||||
const PENDING_CANCEL_TASKS_KEY = "omniai:pending-task-cancellations";
|
||||
|
||||
function readPendingCancelTaskIds(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PENDING_CANCEL_TASKS_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string" && id.trim().length > 0) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writePendingCancelTaskIds(taskIds: string[]): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const uniqueIds = Array.from(new Set(taskIds.filter(Boolean)));
|
||||
if (uniqueIds.length) {
|
||||
window.localStorage.setItem(PENDING_CANCEL_TASKS_KEY, JSON.stringify(uniqueIds));
|
||||
} else {
|
||||
window.localStorage.removeItem(PENDING_CANCEL_TASKS_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Pending cancellation recovery is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
function markTaskCancelPending(taskId: string): void {
|
||||
writePendingCancelTaskIds([...readPendingCancelTaskIds(), taskId]);
|
||||
}
|
||||
|
||||
function clearPendingTaskCancel(taskId: string): void {
|
||||
writePendingCancelTaskIds(readPendingCancelTaskIds().filter((id) => id !== taskId));
|
||||
}
|
||||
|
||||
function shouldRetryTaskCancel(error: unknown): boolean {
|
||||
if (!isServerRequestError(error)) return true;
|
||||
const status = error.status;
|
||||
return status === 429 || status === undefined || status >= 500;
|
||||
}
|
||||
|
||||
export const aiGenerationClient = {
|
||||
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
|
||||
const requestUrl = buildApiUrl("ai/image");
|
||||
@@ -306,13 +250,15 @@ export const aiGenerationClient = {
|
||||
projectId: input.projectId,
|
||||
conversationId: input.conversationId,
|
||||
});
|
||||
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", {
|
||||
const res = await fetch(requestUrl, {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Image generation request failed",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Image generation request failed");
|
||||
}
|
||||
const payload = await readJsonResponse<ImageTaskCreateResponse>(res, "Image generation response failed");
|
||||
if (payload.providerDebug) {
|
||||
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
|
||||
}
|
||||
@@ -320,113 +266,96 @@ export const aiGenerationClient = {
|
||||
},
|
||||
|
||||
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
|
||||
return serverRequest<{ taskId: string }>("ai/video", {
|
||||
const res = await fetch(buildApiUrl("ai/video"), {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Video generation request failed",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
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 }> {
|
||||
return serverRequest<{ taskId: string }>("ai/video/super-resolve", {
|
||||
const res = await fetch(buildApiUrl("ai/video/super-resolve"), {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Video super-resolution request failed",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
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 }> {
|
||||
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", {
|
||||
const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Subtitle removal request failed",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
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 }> {
|
||||
return serverRequest<{ taskId: string }>("ai/video/edit", {
|
||||
const res = await fetch(buildApiUrl("ai/video/edit"), {
|
||||
method: "POST",
|
||||
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",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }),
|
||||
});
|
||||
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 }> {
|
||||
return serverRequest<{ taskId: string }>("ai/image/super-resolve", {
|
||||
const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Image super-resolution request failed",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
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 }> {
|
||||
return serverRequest<{ taskId: string }>("ai/image/edit", {
|
||||
const res = await fetch(buildApiUrl("ai/image/edit"), {
|
||||
method: "POST",
|
||||
body: input,
|
||||
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Image edit request failed",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Image edit request failed");
|
||||
}
|
||||
return readJsonResponse<{ taskId: string }>(res, "Image edit response failed");
|
||||
},
|
||||
|
||||
async cancelTask(taskId: string): Promise<void> {
|
||||
markTaskCancelPending(taskId);
|
||||
try {
|
||||
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
|
||||
method: "PATCH",
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Task cancel failed",
|
||||
});
|
||||
clearPendingTaskCancel(taskId);
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error) || !shouldRetryTaskCancel(error)) {
|
||||
clearPendingTaskCancel(taskId);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
cancelTaskOnUnload(taskId: string): void {
|
||||
markTaskCancelPending(taskId);
|
||||
const url = buildApiUrl(`ai/tasks/${encodeURIComponent(taskId)}/cancel`);
|
||||
const headers = buildAuthHeaders();
|
||||
const body = JSON.stringify({ reason: "page_unload" });
|
||||
|
||||
try {
|
||||
void fetch(url, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body,
|
||||
credentials: "include",
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {
|
||||
// Page unload cancellation is best-effort.
|
||||
}
|
||||
},
|
||||
|
||||
flushPendingTaskCancellations(): void {
|
||||
readPendingCancelTaskIds().forEach((taskId) => {
|
||||
this.cancelTask(taskId).catch(() => {});
|
||||
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");
|
||||
}
|
||||
},
|
||||
|
||||
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
|
||||
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
|
||||
timeoutMs: TASK_STATUS_TIMEOUT_MS,
|
||||
fallbackMessage: "Task status request failed",
|
||||
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), {
|
||||
method: "GET",
|
||||
headers: buildAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await throwResponseError(res, "Task status request failed");
|
||||
}
|
||||
return readJsonResponse<AiTaskStatus>(res, "Task status response failed");
|
||||
},
|
||||
|
||||
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
|
||||
@@ -452,41 +381,49 @@ 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);
|
||||
try {
|
||||
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
|
||||
fallbackMessage: "Task history request failed",
|
||||
});
|
||||
return extractTaskList(payload).map(toPreviewTask);
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error)) {
|
||||
taskHistoryRouteMissing = true;
|
||||
return [];
|
||||
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;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const payload = await readJsonResponse<unknown>(res, "Task history response failed");
|
||||
return extractTaskList(payload).map(toPreviewTask);
|
||||
},
|
||||
|
||||
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
|
||||
try {
|
||||
await serverRequest<void>(`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;
|
||||
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");
|
||||
}
|
||||
},
|
||||
|
||||
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", {
|
||||
const res = await fetch(buildApiUrl("oss/upload"), {
|
||||
method: "POST",
|
||||
body: input,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Asset upload failed",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
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 }> {
|
||||
@@ -508,30 +445,20 @@ export const aiGenerationClient = {
|
||||
},
|
||||
|
||||
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
|
||||
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", {
|
||||
const res = await fetch(buildApiUrl("oss/upload-by-url"), {
|
||||
method: "POST",
|
||||
body: input,
|
||||
maxRetries: NON_RETRYING_REQUEST.maxRetries,
|
||||
fallbackMessage: "Asset upload by URL failed",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
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(
|
||||
taskId: string,
|
||||
onUpdate: (
|
||||
task: Pick<
|
||||
AiTaskStatus,
|
||||
| "taskId"
|
||||
| "status"
|
||||
| "progress"
|
||||
| "progressSource"
|
||||
| "stage"
|
||||
| "startedAt"
|
||||
| "expectedDurationMs"
|
||||
| "resultUrl"
|
||||
| "error"
|
||||
>,
|
||||
) => void,
|
||||
onUpdate: (task: Pick<AiTaskStatus, "taskId" | "status" | "progress" | "resultUrl" | "error">) => void,
|
||||
): () => void {
|
||||
const url = buildApiUrl(`ai/tasks/${taskId}/stream`);
|
||||
const controller = new AbortController();
|
||||
@@ -573,7 +500,6 @@ export const aiGenerationClient = {
|
||||
input: ChatInput,
|
||||
onChunk: (text: string) => void,
|
||||
signal?: AbortSignal,
|
||||
onUsage?: (usage: ChatUsage) => void,
|
||||
): Promise<void> {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
@@ -586,7 +512,7 @@ export const aiGenerationClient = {
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41");
|
||||
if (!reader) throw new Error("无法读取响应流");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
@@ -603,24 +529,8 @@ export const aiGenerationClient = {
|
||||
const payload = line.slice(6).trim();
|
||||
if (!payload) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(payload) as {
|
||||
delta?: string;
|
||||
done?: boolean;
|
||||
error?: string;
|
||||
usage?: ChatUsage & {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string };
|
||||
if (chunk.error) throw new Error(chunk.error);
|
||||
if (chunk.usage) {
|
||||
onUsage?.({
|
||||
promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens,
|
||||
completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens,
|
||||
totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens,
|
||||
});
|
||||
}
|
||||
if (chunk.delta) onChunk(chunk.delta);
|
||||
if (chunk.done) return;
|
||||
} catch (e) {
|
||||
|
||||
@@ -67,13 +67,7 @@ function normalizeAssetStatus(value: unknown): WebAssetItem["status"] {
|
||||
}
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const tags: string[] = [];
|
||||
for (const item of value) {
|
||||
const tag = toStringValue(item);
|
||||
if (tag) tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
return Array.isArray(value) ? value.map((item) => toStringValue(item)).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function normalizeAsset(raw: unknown): ServerAssetItem {
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { serverRequest } from "./serverConnection";
|
||||
|
||||
export interface BetaApplicationInput {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
wechat: string;
|
||||
industry: string;
|
||||
company: string;
|
||||
city: string;
|
||||
aiTools: string;
|
||||
aiDuration: string;
|
||||
aiTrack: string;
|
||||
aiDirection: string[];
|
||||
weeklyUsage: string;
|
||||
feedbackWilling: string;
|
||||
wantFeature: string[];
|
||||
selfStatement: string;
|
||||
signature: string;
|
||||
applicationDate: string;
|
||||
agreeRules: boolean;
|
||||
}
|
||||
|
||||
export type BetaApplicationStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export interface BetaApplicationItem extends BetaApplicationInput {
|
||||
id: number;
|
||||
userId: number | null;
|
||||
username: string | null;
|
||||
status: BetaApplicationStatus;
|
||||
inviteCode: string | null;
|
||||
reviewNote: string | null;
|
||||
reviewedBy: number | null;
|
||||
reviewerUsername: string | null;
|
||||
reviewedAt: string | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BetaApplicationSubmitResult {
|
||||
id: number;
|
||||
status: BetaApplicationStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function readNullableString(value: unknown): string | null {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function readNumberOrNull(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const next = Number(value);
|
||||
return Number.isFinite(next) ? next : null;
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): BetaApplicationStatus {
|
||||
return value === "approved" || value === "rejected" ? value : "pending";
|
||||
}
|
||||
|
||||
function normalizeApplication(raw: unknown): BetaApplicationItem {
|
||||
const item = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
||||
return {
|
||||
id: Number(item.id) || 0,
|
||||
userId: readNumberOrNull(item.userId),
|
||||
username: readNullableString(item.username),
|
||||
name: readString(item.name),
|
||||
email: readString(item.email),
|
||||
phone: readString(item.phone),
|
||||
wechat: readString(item.wechat),
|
||||
industry: readString(item.industry),
|
||||
company: readString(item.company),
|
||||
city: readString(item.city),
|
||||
aiTools: readString(item.aiTools),
|
||||
aiDuration: readString(item.aiDuration),
|
||||
aiTrack: readString(item.aiTrack),
|
||||
aiDirection: readStringArray(item.aiDirection),
|
||||
weeklyUsage: readString(item.weeklyUsage),
|
||||
feedbackWilling: readString(item.feedbackWilling),
|
||||
wantFeature: readStringArray(item.wantFeature),
|
||||
selfStatement: readString(item.selfStatement),
|
||||
signature: readString(item.signature),
|
||||
applicationDate: readString(item.applicationDate),
|
||||
agreeRules: item.agreeRules === true,
|
||||
status: normalizeStatus(item.status),
|
||||
inviteCode: readNullableString(item.inviteCode),
|
||||
reviewNote: readNullableString(item.reviewNote),
|
||||
reviewedBy: readNumberOrNull(item.reviewedBy),
|
||||
reviewerUsername: readNullableString(item.reviewerUsername),
|
||||
reviewedAt: readNullableString(item.reviewedAt),
|
||||
ipAddress: readNullableString(item.ipAddress),
|
||||
userAgent: readNullableString(item.userAgent),
|
||||
createdAt: readString(item.createdAt),
|
||||
updatedAt: readString(item.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export const betaApplicationClient = {
|
||||
async submit(input: BetaApplicationInput): Promise<BetaApplicationSubmitResult> {
|
||||
const payload = await serverRequest<{ application: BetaApplicationSubmitResult }>("beta-applications", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
maxRetries: 0,
|
||||
fallbackMessage: "提交内测申请失败",
|
||||
});
|
||||
return payload.application;
|
||||
},
|
||||
|
||||
async listAdminApplications(status?: BetaApplicationStatus | ""): Promise<BetaApplicationItem[]> {
|
||||
const query = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
const payload = await serverRequest<{ applications?: unknown[] }>(`admin/beta-applications${query}`, {
|
||||
fallbackMessage: "读取内测申请失败",
|
||||
});
|
||||
return Array.isArray(payload.applications) ? payload.applications.map(normalizeApplication) : [];
|
||||
},
|
||||
|
||||
async reviewApplication(
|
||||
id: number,
|
||||
action: "approve" | "reject",
|
||||
reviewNote?: string,
|
||||
): Promise<BetaApplicationItem> {
|
||||
const payload = await serverRequest<{ application: unknown }>(`admin/beta-applications/${id}`, {
|
||||
method: "PATCH",
|
||||
body: { action, reviewNote },
|
||||
maxRetries: 0,
|
||||
fallbackMessage: "审核内测申请失败",
|
||||
});
|
||||
return normalizeApplication(payload.application);
|
||||
},
|
||||
};
|
||||
@@ -62,13 +62,9 @@ function toStringValue(value: unknown, fallback = ""): string {
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const result: string[] = [];
|
||||
for (const item of value) {
|
||||
const text = toStringValue(item);
|
||||
if (text) result.push(text);
|
||||
}
|
||||
return result;
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => toStringValue(item)).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function toMetadata(value: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
__resetGenerationConcurrencyForTests,
|
||||
claimGenerationSlot,
|
||||
getActiveGenerationTaskCount,
|
||||
getEffectiveGenerationLimit,
|
||||
getGenerationUserKey,
|
||||
releaseGenerationSlot,
|
||||
setUserMaxConcurrency,
|
||||
} from "./generationConcurrency";
|
||||
|
||||
describe("generationConcurrency", () => {
|
||||
afterEach(() => {
|
||||
__resetGenerationConcurrencyForTests();
|
||||
});
|
||||
|
||||
it("uses the default generation limit until the server provides a user-specific limit", () => {
|
||||
expect(getEffectiveGenerationLimit()).toBe(3);
|
||||
|
||||
setUserMaxConcurrency(5);
|
||||
expect(getEffectiveGenerationLimit()).toBe(5);
|
||||
|
||||
setUserMaxConcurrency(0);
|
||||
expect(getEffectiveGenerationLimit()).toBe(3);
|
||||
});
|
||||
|
||||
it("claims and releases local generation slots so the submit button can recover", () => {
|
||||
const userKey = getGenerationUserKey("user-1");
|
||||
|
||||
const releaseFirst = claimGenerationSlot({
|
||||
userKey,
|
||||
kind: "image",
|
||||
id: "slot-1",
|
||||
});
|
||||
claimGenerationSlot({ userKey, kind: "video", id: "slot-2" });
|
||||
|
||||
expect(getActiveGenerationTaskCount(userKey)).toBe(2);
|
||||
|
||||
releaseFirst();
|
||||
expect(getActiveGenerationTaskCount(userKey)).toBe(1);
|
||||
|
||||
releaseGenerationSlot("slot-2");
|
||||
expect(getActiveGenerationTaskCount(userKey)).toBe(0);
|
||||
});
|
||||
|
||||
it("enforces per-user limits without blocking other users", () => {
|
||||
setUserMaxConcurrency(1);
|
||||
|
||||
claimGenerationSlot({ userKey: "alice", kind: "image", id: "alice-slot" });
|
||||
|
||||
expect(() =>
|
||||
claimGenerationSlot({
|
||||
userKey: "alice",
|
||||
kind: "video",
|
||||
id: "alice-slot-2",
|
||||
}),
|
||||
).toThrow("最多生成 1 个图片/视频任务");
|
||||
expect(() =>
|
||||
claimGenerationSlot({ userKey: "bob", kind: "video", id: "bob-slot" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -7,24 +7,10 @@ interface GenerationSlot {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ACTIVE_GENERATION_TASKS = 3;
|
||||
const MAX_ACTIVE_GENERATION_TASKS = 3;
|
||||
const STALE_SLOT_MS = 6 * 60 * 60 * 1000;
|
||||
const activeSlots = new Map<string, GenerationSlot>();
|
||||
|
||||
let userMaxConcurrency: number | null = null;
|
||||
|
||||
export function setUserMaxConcurrency(limit: number | null | undefined): void {
|
||||
userMaxConcurrency = typeof limit === "number" && limit > 0 ? limit : null;
|
||||
}
|
||||
|
||||
function getEffectiveLimit(): number {
|
||||
return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS;
|
||||
}
|
||||
|
||||
export function getEffectiveGenerationLimit(): number {
|
||||
return getEffectiveLimit();
|
||||
}
|
||||
|
||||
export function getGenerationUserKey(userId?: string | number | null): string {
|
||||
return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId);
|
||||
}
|
||||
@@ -53,9 +39,8 @@ export function claimGenerationSlot(input: {
|
||||
}): () => void {
|
||||
pruneStaleSlots();
|
||||
const activeCount = getActiveGenerationTaskCount(input.userKey);
|
||||
const effectiveLimit = getEffectiveLimit();
|
||||
if (activeCount >= effectiveLimit) {
|
||||
throw new Error(`当前账号同时最多生成 ${effectiveLimit} 个图片/视频任务,请等待已有任务完成后再提交。`);
|
||||
if (activeCount >= MAX_ACTIVE_GENERATION_TASKS) {
|
||||
throw new Error("当前账号同时最多生成 3 个图片/视频任务,请等待已有任务完成后再提交。");
|
||||
}
|
||||
|
||||
const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -75,8 +60,3 @@ export function releaseGenerationSlot(id: string | undefined | null): void {
|
||||
if (!id) return;
|
||||
activeSlots.delete(id);
|
||||
}
|
||||
|
||||
export function __resetGenerationConcurrencyForTests(): void {
|
||||
activeSlots.clear();
|
||||
userMaxConcurrency = null;
|
||||
}
|
||||
|
||||
@@ -376,18 +376,13 @@ function createWorkflowFingerprint(workflow: WebCanvasWorkflow): string {
|
||||
function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
|
||||
const packages: NonNullable<WebUserSession["user"]["activePackages"]> = [];
|
||||
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;
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeUser(raw: unknown): WebUserSession["user"] | null {
|
||||
@@ -434,7 +429,6 @@ function normalizeUser(raw: unknown): WebUserSession["user"] | null {
|
||||
candidate.enterpriseBalance ??
|
||||
candidate.enterprise_balance,
|
||||
),
|
||||
maxConcurrency: toNumber(candidate.maxConcurrency ?? candidate.max_concurrency),
|
||||
activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages),
|
||||
};
|
||||
}
|
||||
@@ -481,7 +475,7 @@ function migrateLegacyWorkflowData(old: Record<string, unknown>, wrapper: Record
|
||||
description: String(wrapper.description || ""),
|
||||
source: (wrapper.source as WebCanvasWorkflow["source"]) || "blank",
|
||||
settings: {
|
||||
model: String(isRecord(old.settings) ? old.settings.model || "omni-水果 Pro" : "omni-水果 Pro"),
|
||||
model: String(isRecord(old.settings) ? old.settings.model || "Nano Banana Pro" : "Nano Banana Pro"),
|
||||
ratio: String(isRecord(old.settings) ? old.settings.ratio || "1:1" : "1:1"),
|
||||
duration: String(isRecord(old.settings) ? old.settings.duration || "0s" : "0s"),
|
||||
resolution: String(isRecord(old.settings) ? old.settings.resolution || "2K" : "2K"),
|
||||
@@ -919,7 +913,7 @@ export const keyServerClient = {
|
||||
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
|
||||
const stored = readStoredSession();
|
||||
if (!stored) {
|
||||
throw new Error("需要先登录");
|
||||
throw new Error("闇€瑕佸厛鐧诲綍");
|
||||
}
|
||||
|
||||
const safeProjectId = encodeURIComponent(projectId.trim());
|
||||
@@ -1006,7 +1000,7 @@ export const keyServerClient = {
|
||||
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
|
||||
const stored = readStoredSession();
|
||||
if (!stored) {
|
||||
throw new Error("需要先登录");
|
||||
throw new Error("闇€瑕佸厛鐧诲綍");
|
||||
}
|
||||
|
||||
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
|
||||
|
||||
@@ -38,14 +38,9 @@ function normalizeModelOption(raw: unknown): ModelCapabilityOption | null {
|
||||
const enabled = raw.enabled === undefined ? status !== "maintenance" && status !== "disabled" : Boolean(raw.enabled);
|
||||
if (!enabled) return null;
|
||||
|
||||
const label = toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value);
|
||||
|
||||
return {
|
||||
value,
|
||||
label:
|
||||
value === "wan2.7-image-pro"
|
||||
? label.replace(/\s*4k\b/i, "").trim() || "wan 2.7 Pro"
|
||||
: label,
|
||||
label: toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value),
|
||||
description: toStringValue(raw.description) || undefined,
|
||||
badge: toStringValue(raw.badge) || undefined,
|
||||
enabled,
|
||||
@@ -54,13 +49,9 @@ function normalizeModelOption(raw: unknown): ModelCapabilityOption | null {
|
||||
}
|
||||
|
||||
function normalizeModelList(value: unknown): ModelCapabilityOption[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const options: ModelCapabilityOption[] = [];
|
||||
for (const item of value) {
|
||||
const option = normalizeModelOption(item);
|
||||
if (option) options.push(option);
|
||||
}
|
||||
return options;
|
||||
return Array.isArray(value)
|
||||
? value.map(normalizeModelOption).filter((item): item is ModelCapabilityOption => Boolean(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
function createFallbackCapabilities(): WebModelCapabilities {
|
||||
@@ -76,11 +67,12 @@ let modelCapabilitiesRouteMissing = false;
|
||||
|
||||
export const modelCapabilitiesClient = {
|
||||
async get(name = "web-model-capabilities"): Promise<WebModelCapabilities> {
|
||||
if (import.meta.env.DEV && name === "web-model-capabilities") return createFallbackCapabilities();
|
||||
if (modelCapabilitiesRouteMissing) return createFallbackCapabilities();
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await serverRequest<unknown>(`public/config/profile?name=${encodeURIComponent(name)}`);
|
||||
payload = await serverRequest<unknown>(`config/profile?name=${encodeURIComponent(name)}`);
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error)) {
|
||||
modelCapabilitiesRouteMissing = true;
|
||||
|
||||
@@ -71,19 +71,10 @@ function normalizeTask(raw: unknown): ServerProjectTask | null {
|
||||
}
|
||||
|
||||
function extractTasks(payload: unknown): 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 (Array.isArray(payload)) return payload.map(normalizeTask).filter(Boolean) as ServerProjectTask[];
|
||||
if (!isRecord(payload)) return [];
|
||||
const rows = payload.tasks ?? payload.items;
|
||||
return Array.isArray(rows) ? normalizeTasks(rows) : [];
|
||||
return Array.isArray(rows) ? (rows.map(normalizeTask).filter(Boolean) as ServerProjectTask[]) : [];
|
||||
}
|
||||
|
||||
function taskTitle(task: ServerProjectTask): string {
|
||||
@@ -119,12 +110,8 @@ export const projectTaskClient = {
|
||||
},
|
||||
|
||||
async listForProjects(projectIds: string[]): Promise<WebGenerationPreviewTask[]> {
|
||||
const uniqueIds = new Set<string>();
|
||||
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)));
|
||||
const uniqueIds = Array.from(new Set(projectIds.map((id) => id.trim()).filter(Boolean)));
|
||||
const results = await Promise.all(uniqueIds.map((id) => listProjectTasks(id)));
|
||||
return results.flat();
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { serverRequest } from "./serverConnection";
|
||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
|
||||
export interface ProviderHealthEntry {
|
||||
status: string;
|
||||
@@ -32,8 +32,13 @@ export interface ProviderHealthResponse {
|
||||
|
||||
export const providerHealthClient = {
|
||||
async getStatus(): Promise<ProviderHealthResponse> {
|
||||
return serverRequest<ProviderHealthResponse>("admin/providers/status", {
|
||||
fallbackMessage: "Provider health request failed",
|
||||
const res = await fetch(buildApiUrl("admin/providers/status"), {
|
||||
method: "GET",
|
||||
headers: buildAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Provider health request failed (${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<ProviderHealthResponse>;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||
import { isRecord, serverRequest } from "./serverConnection";
|
||||
|
||||
export interface WebPublicConfig {
|
||||
contactEmail?: string;
|
||||
contactPhone?: string;
|
||||
companyAddress?: string;
|
||||
icpRecord?: string;
|
||||
}
|
||||
|
||||
function readString(config: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = config[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePublicConfig(raw: unknown): WebPublicConfig {
|
||||
const config = isRecord(raw) && isRecord(raw.config) ? raw.config : raw;
|
||||
if (!isRecord(config)) return {};
|
||||
|
||||
return {
|
||||
contactEmail: readString(config, ["contactEmail", "contact_email", "supportEmail", "support_email"]),
|
||||
contactPhone: readString(config, ["contactPhone", "contact_phone", "supportPhone", "support_phone"]),
|
||||
companyAddress: readString(config, ["companyAddress", "company_address", "address"]),
|
||||
icpRecord: readString(config, ["icpRecord", "icp_record", "filingInfo", "filing_info"]),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedPublicConfig: WebPublicConfig | null = null;
|
||||
let publicConfigRouteMissing = false;
|
||||
|
||||
export const publicConfigClient = {
|
||||
async get(): Promise<WebPublicConfig> {
|
||||
if (cachedPublicConfig) return cachedPublicConfig;
|
||||
if (publicConfigRouteMissing) return {};
|
||||
|
||||
try {
|
||||
const payload = await serverRequest<unknown>("public/config/profile?name=web-public-config");
|
||||
cachedPublicConfig = normalizePublicConfig(payload);
|
||||
return cachedPublicConfig;
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error)) {
|
||||
publicConfigRouteMissing = true;
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
import { describe, expect, it } from "../test/testHarness";
|
||||
|
||||
import {
|
||||
normalizeEnterpriseVideoPricingConfig,
|
||||
normalizePublicModelPrice,
|
||||
normalizePublicModelPrices,
|
||||
normalizePublicPricingPayload,
|
||||
} from "./publicPricingClient";
|
||||
|
||||
describe("publicPricingClient", () => {
|
||||
it("normalizes camelCase public model price payloads", () => {
|
||||
expect(
|
||||
normalizePublicModelPrice({
|
||||
id: 1,
|
||||
modelKey: "gpt-4o",
|
||||
displayName: "GPT-4o",
|
||||
category: "text",
|
||||
pricingType: "token",
|
||||
inputPriceMills: 27,
|
||||
outputPriceMills: 108,
|
||||
flatPriceMills: null,
|
||||
currency: "CNY",
|
||||
enabled: true,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 1,
|
||||
modelKey: "gpt-4o",
|
||||
displayName: "GPT-4o",
|
||||
category: "text",
|
||||
pricingType: "token",
|
||||
inputPriceMills: 27,
|
||||
outputPriceMills: 108,
|
||||
flatPriceMills: null,
|
||||
currency: "CNY",
|
||||
enabled: true,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes snake_case public model price payloads inside containers", () => {
|
||||
expect(
|
||||
normalizePublicModelPrices({
|
||||
prices: [
|
||||
{
|
||||
model_key: "deepseek-chat",
|
||||
display_name: "DeepSeek Chat",
|
||||
pricing_type: "token",
|
||||
input_price_mills: "2",
|
||||
output_price_mills: "8",
|
||||
flat_price_mills: "0",
|
||||
enabled: 1,
|
||||
},
|
||||
{ display_name: "missing key" },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: undefined,
|
||||
modelKey: "deepseek-chat",
|
||||
displayName: "DeepSeek Chat",
|
||||
category: undefined,
|
||||
pricingType: "token",
|
||||
inputPriceMills: 2,
|
||||
outputPriceMills: 8,
|
||||
flatPriceMills: 0,
|
||||
currency: "CNY",
|
||||
enabled: true,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes public pricing payloads with model prices and enterprise video pricing", () => {
|
||||
expect(
|
||||
normalizePublicPricingPayload({
|
||||
modelPrices: [
|
||||
{
|
||||
modelKey: "qwen-turbo",
|
||||
pricingType: "token",
|
||||
inputPriceMills: 2,
|
||||
outputPriceMills: 6,
|
||||
},
|
||||
],
|
||||
enterpriseVideoPricing: {
|
||||
currency: "CNY",
|
||||
creditsPerCny: 100,
|
||||
billingUnit: "per_second",
|
||||
defaultResolution: "1080P",
|
||||
resolutions: ["720P", "1080P"],
|
||||
rules: [
|
||||
{
|
||||
id: "happyhorse",
|
||||
modelIncludes: ["happyhorse"],
|
||||
rates: { "720P": 0.72, "1080P": 1.28 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
modelPrices: [
|
||||
{
|
||||
id: undefined,
|
||||
modelKey: "qwen-turbo",
|
||||
displayName: undefined,
|
||||
category: undefined,
|
||||
pricingType: "token",
|
||||
inputPriceMills: 2,
|
||||
outputPriceMills: 6,
|
||||
flatPriceMills: null,
|
||||
currency: "CNY",
|
||||
enabled: true,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
],
|
||||
enterpriseVideoPricing: {
|
||||
currency: "CNY",
|
||||
creditsPerCny: 100,
|
||||
billingUnit: "per_second",
|
||||
defaultResolution: "1080P",
|
||||
resolutions: ["720P", "1080P"],
|
||||
rules: [
|
||||
{
|
||||
id: "happyhorse",
|
||||
modelIncludes: ["happyhorse"],
|
||||
rates: { "720P": 0.72, "1080P": 1.28 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed enterprise video pricing configs", () => {
|
||||
expect(
|
||||
normalizeEnterpriseVideoPricingConfig({
|
||||
rules: [{ id: "broken", modelIncludes: [], rates: {} }],
|
||||
}),
|
||||
).toEqual(null);
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
|
||||
import { isRecord, serverRequest } from "./serverConnection";
|
||||
import type { EnterpriseVideoPricingConfig, EnterpriseVideoPricingRule } from "../utils/enterpriseVideoPolicy";
|
||||
|
||||
export interface PublicModelPrice {
|
||||
id?: number | string;
|
||||
modelKey: string;
|
||||
displayName?: string;
|
||||
category?: string;
|
||||
pricingType?: string;
|
||||
inputPriceMills: number | null;
|
||||
outputPriceMills: number | null;
|
||||
flatPriceMills: number | null;
|
||||
currency: string;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PublicPricingPayload {
|
||||
modelPrices: PublicModelPrice[];
|
||||
enterpriseVideoPricing: EnterpriseVideoPricingConfig | null;
|
||||
}
|
||||
|
||||
function readString(
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumber(
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): number | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number(value)
|
||||
: NaN;
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readBoolean(
|
||||
record: Record<string, unknown>,
|
||||
keys: string[],
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "enabled"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "disabled"].includes(normalized)) return false;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readStringArray(record: Record<string, unknown>, keys: string[]): string[] {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (!Array.isArray(value)) continue;
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeRateMap(raw: unknown): Record<string, number> | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
||||
if (Number.isFinite(parsed) && parsed >= 0) result[key] = parsed;
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function normalizeEnterpriseVideoPricingRule(raw: unknown): EnterpriseVideoPricingRule | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const id = readString(raw, ["id", "key", "name"]);
|
||||
const modelIncludes = readStringArray(raw, ["modelIncludes", "model_includes", "modelPatterns", "model_patterns"]);
|
||||
const rates = normalizeRateMap(raw.rates);
|
||||
if (!id || modelIncludes.length === 0 || !rates) return null;
|
||||
|
||||
const when = isRecord(raw.when)
|
||||
? {
|
||||
...(typeof raw.when.muted === "boolean" ? { muted: raw.when.muted } : {}),
|
||||
...(typeof raw.when.hasReferenceVideo === "boolean"
|
||||
? { hasReferenceVideo: raw.when.hasReferenceVideo }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id,
|
||||
modelIncludes,
|
||||
...(when && Object.keys(when).length ? { when } : {}),
|
||||
rates,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePublicModelPrice(
|
||||
raw: unknown,
|
||||
): PublicModelPrice | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
|
||||
const modelKey = readString(raw, ["modelKey", "model_key", "key", "model"]);
|
||||
if (!modelKey) return null;
|
||||
|
||||
const displayName = readString(raw, ["displayName", "display_name", "name"]);
|
||||
const category = readString(raw, ["category", "type"]);
|
||||
const pricingType = readString(raw, ["pricingType", "pricing_type"]);
|
||||
const currency = readString(raw, ["currency"]) || "CNY";
|
||||
const createdAt = readString(raw, ["createdAt", "created_at"]);
|
||||
const updatedAt = readString(raw, ["updatedAt", "updated_at"]);
|
||||
const idValue = raw.id;
|
||||
|
||||
return {
|
||||
id:
|
||||
typeof idValue === "number" || typeof idValue === "string"
|
||||
? idValue
|
||||
: undefined,
|
||||
modelKey,
|
||||
displayName,
|
||||
category,
|
||||
pricingType,
|
||||
inputPriceMills: readNumber(raw, ["inputPriceMills", "input_price_mills"]),
|
||||
outputPriceMills: readNumber(raw, [
|
||||
"outputPriceMills",
|
||||
"output_price_mills",
|
||||
]),
|
||||
flatPriceMills: readNumber(raw, ["flatPriceMills", "flat_price_mills"]),
|
||||
currency,
|
||||
enabled: readBoolean(raw, ["enabled", "is_enabled"], true),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePublicModelPrices(
|
||||
payload: unknown,
|
||||
): PublicModelPrice[] {
|
||||
const rawPrices = Array.isArray(payload)
|
||||
? payload
|
||||
: isRecord(payload) && Array.isArray(payload.prices)
|
||||
? payload.prices
|
||||
: isRecord(payload) && Array.isArray(payload.modelPrices)
|
||||
? payload.modelPrices
|
||||
: isRecord(payload) && Array.isArray(payload.model_prices)
|
||||
? payload.model_prices
|
||||
: isRecord(payload) && Array.isArray(payload.models)
|
||||
? payload.models
|
||||
: [];
|
||||
|
||||
return rawPrices
|
||||
.map((item) => normalizePublicModelPrice(item))
|
||||
.filter((item): item is PublicModelPrice => Boolean(item));
|
||||
}
|
||||
|
||||
export function normalizeEnterpriseVideoPricingConfig(raw: unknown): EnterpriseVideoPricingConfig | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const rules = Array.isArray(raw.rules)
|
||||
? raw.rules
|
||||
.map((item) => normalizeEnterpriseVideoPricingRule(item))
|
||||
.filter((item): item is EnterpriseVideoPricingRule => Boolean(item))
|
||||
: [];
|
||||
if (rules.length === 0) return null;
|
||||
|
||||
const creditsPerCny = readNumber(raw, ["creditsPerCny", "credits_per_cny"]);
|
||||
const defaultResolution = readString(raw, ["defaultResolution", "default_resolution"]);
|
||||
const billingUnit = readString(raw, ["billingUnit", "billing_unit"]);
|
||||
const currency = readString(raw, ["currency"]);
|
||||
const resolutions = readStringArray(raw, ["resolutions", "supportedResolutions", "supported_resolutions"]);
|
||||
|
||||
return {
|
||||
...(currency ? { currency } : {}),
|
||||
...(creditsPerCny !== null ? { creditsPerCny } : {}),
|
||||
...(billingUnit ? { billingUnit } : {}),
|
||||
...(defaultResolution ? { defaultResolution } : {}),
|
||||
...(resolutions.length ? { resolutions } : {}),
|
||||
rules,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePublicPricingPayload(payload: unknown): PublicPricingPayload {
|
||||
const enterpriseVideoPricingRaw =
|
||||
isRecord(payload) && (payload.enterpriseVideoPricing ?? payload.enterprise_video_pricing);
|
||||
|
||||
return {
|
||||
modelPrices: normalizePublicModelPrices(payload),
|
||||
enterpriseVideoPricing: normalizeEnterpriseVideoPricingConfig(enterpriseVideoPricingRaw),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedPricing: PublicPricingPayload | null = null;
|
||||
let pricesRouteMissing = false;
|
||||
|
||||
export const publicPricingClient = {
|
||||
async getPricing(): Promise<PublicPricingPayload> {
|
||||
if (cachedPricing) return cachedPricing;
|
||||
if (pricesRouteMissing) return { modelPrices: [], enterpriseVideoPricing: null };
|
||||
|
||||
try {
|
||||
const payload = await serverRequest<unknown>("prices", {
|
||||
fallbackMessage: "Model prices request failed",
|
||||
});
|
||||
cachedPricing = normalizePublicPricingPayload(payload);
|
||||
return cachedPricing;
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error)) {
|
||||
pricesRouteMissing = true;
|
||||
return { modelPrices: [], enterpriseVideoPricing: null };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getPrices(): Promise<PublicModelPrice[]> {
|
||||
const pricing = await publicPricingClient.getPricing();
|
||||
return pricing.modelPrices;
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import { serverRequest } from "./serverConnection";
|
||||
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
||||
|
||||
export interface ScriptEvalResult {
|
||||
totalScore: number;
|
||||
grade: string;
|
||||
dimensionScores: Record<string, number>;
|
||||
subScores?: Record<string, Record<string, number>>;
|
||||
evidence?: Record<string, string[]>;
|
||||
summary: string;
|
||||
issues: string[];
|
||||
highlights: string[];
|
||||
@@ -14,33 +12,6 @@ export interface ScriptEvalResult {
|
||||
|
||||
const MODEL = "qwen3.7-max";
|
||||
|
||||
const EVAL_OUTPUT_CONTRACT = `
|
||||
强制输出 JSON,主维度键名必须严格为:
|
||||
hook(20), plot(20), character(15), logic(15), visual(15), content(15)。
|
||||
不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。
|
||||
|
||||
同时返回 subScores 和 evidence:
|
||||
- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。
|
||||
- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。
|
||||
|
||||
返回结构:
|
||||
{
|
||||
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 },
|
||||
"subScores": {
|
||||
"hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 },
|
||||
"plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 },
|
||||
"character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 },
|
||||
"logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 },
|
||||
"visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 },
|
||||
"content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 }
|
||||
},
|
||||
"evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] },
|
||||
"summary": "200-300字综合评价",
|
||||
"issues": ["具体扣分点,带维度和证据", ...],
|
||||
"highlights": ["具体亮点,带维度和证据", ...],
|
||||
"suggestions": ["按优先级排列的改稿建议", ...]
|
||||
}`;
|
||||
|
||||
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
|
||||
|
||||
【剧本类型识别】
|
||||
@@ -75,10 +46,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有
|
||||
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
|
||||
hook: { maxScore: 20 },
|
||||
plot: { maxScore: 20 },
|
||||
character: { maxScore: 15 },
|
||||
logic: { maxScore: 15 },
|
||||
character: { maxScore: 18 },
|
||||
dialogue: { maxScore: 15 },
|
||||
visual: { maxScore: 15 },
|
||||
content: { maxScore: 15 },
|
||||
content: { maxScore: 12 },
|
||||
};
|
||||
|
||||
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
|
||||
@@ -97,83 +68,29 @@ function extractJson(text: string): unknown {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function normalizeScoreValue(value: unknown, maxScore: number): number {
|
||||
const score = Number(value);
|
||||
if (!Number.isFinite(score)) return 0;
|
||||
return Math.max(0, Math.min(maxScore, Math.round(score * 10) / 10));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, Record<string, number>> {
|
||||
if (!isRecord(value)) return {};
|
||||
|
||||
const normalized: Record<string, Record<string, number>> = {};
|
||||
for (const [dimensionKey, dimension] of Object.entries(DIMENSION_WEIGHTS)) {
|
||||
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
|
||||
if (!isRecord(source)) continue;
|
||||
|
||||
const entries = Object.entries(source)
|
||||
.map(([key, score]) => [key, normalizeScoreValue(score, dimension.maxScore)] as const)
|
||||
.filter(([, score]) => score > 0);
|
||||
if (entries.length > 0) normalized[dimensionKey] = Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeEvidence(value: unknown): Record<string, string[]> {
|
||||
if (!isRecord(value)) return {};
|
||||
|
||||
const normalized: Record<string, string[]> = {};
|
||||
for (const dimensionKey of Object.keys(DIMENSION_WEIGHTS)) {
|
||||
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
|
||||
if (!Array.isArray(source)) continue;
|
||||
|
||||
const items = normalizeEvidenceItems(source, 3);
|
||||
if (items.length > 0) normalized[dimensionKey] = items;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
||||
const payload = await serverRequest<{
|
||||
content?: string;
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
text?: string;
|
||||
}>("ai/chat", {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
body: {
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: EVAL_SYSTEM_PROMPT },
|
||||
{ role: "system", content: EVAL_OUTPUT_CONTRACT },
|
||||
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
|
||||
],
|
||||
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("模型未返回有效内容");
|
||||
@@ -184,8 +101,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
||||
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
|
||||
|
||||
for (const key of Object.keys(DIMENSION_WEIGHTS)) {
|
||||
const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
|
||||
dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
|
||||
const val = Number(rawScores[key] ?? 0);
|
||||
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val));
|
||||
}
|
||||
|
||||
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
|
||||
@@ -194,8 +111,6 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
||||
totalScore,
|
||||
grade,
|
||||
dimensionScores,
|
||||
subScores: normalizeNestedScores(parsed.subScores),
|
||||
evidence: normalizeEvidence(parsed.evidence),
|
||||
summary: String(parsed.summary || ""),
|
||||
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
|
||||
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { WebUserSession } from "../types";
|
||||
|
||||
export const DEFAULT_SERVER_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||
export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session";
|
||||
export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced";
|
||||
export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired";
|
||||
@@ -22,9 +23,6 @@ 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;
|
||||
@@ -61,12 +59,34 @@ export function compactMessage(value: string): string {
|
||||
}
|
||||
|
||||
export function getServerBaseUrl(): string {
|
||||
return "";
|
||||
const envBaseUrl = String(
|
||||
import.meta.env.VITE_KEY_SERVER_URL ||
|
||||
import.meta.env.VITE_SERVER_BASE_URL ||
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
"",
|
||||
).trim();
|
||||
const shouldUseSameOriginApi =
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.protocol === "https:" ||
|
||||
window.location.hostname === "omniai.net.cn" ||
|
||||
window.location.hostname === "www.omniai.net.cn");
|
||||
const rawBaseUrl = envBaseUrl || (shouldUseSameOriginApi ? "" : DEFAULT_SERVER_BASE_URL);
|
||||
if (!rawBaseUrl || rawBaseUrl.replace(/\/+$/, "").toLowerCase() === "/api") {
|
||||
return "";
|
||||
}
|
||||
return rawBaseUrl.replace(/\/+$/, "").replace(/\/api$/i, "");
|
||||
}
|
||||
|
||||
export function buildApiUrl(path: string): string {
|
||||
const cleanPath = path.replace(/^\/+/, "");
|
||||
return `/api/${cleanPath}`;
|
||||
const baseUrl = getServerBaseUrl();
|
||||
if (!baseUrl) return `/api/${cleanPath}`;
|
||||
|
||||
try {
|
||||
return new URL(`api/${cleanPath}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
|
||||
} catch {
|
||||
return `${baseUrl}/api/${cleanPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function canUseSessionStorage(): boolean {
|
||||
@@ -147,39 +167,6 @@ export function writeStoredSession(session: WebUserSession | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllUserStorage(): void {
|
||||
writeStoredSession(null);
|
||||
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
const legacyKeys = ["omniai:token", "omniai:session"];
|
||||
for (const key of legacyKeys) {
|
||||
window.localStorage.removeItem(key);
|
||||
window.sessionStorage.removeItem(key);
|
||||
}
|
||||
const prefixKeys = [
|
||||
"omniai-web-profile-ui",
|
||||
"omniai:more-recent-tools",
|
||||
"omniai:generation-queue",
|
||||
"omniai-canvas-saved-assets",
|
||||
];
|
||||
for (let i = window.localStorage.length - 1; i >= 0; i--) {
|
||||
const key = window.localStorage.key(i);
|
||||
if (key && prefixKeys.some((p) => key.startsWith(p))) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
for (let i = window.sessionStorage.length - 1; i >= 0; i--) {
|
||||
const key = window.sessionStorage.key(i);
|
||||
if (key && prefixKeys.some((p) => key.startsWith(p))) {
|
||||
window.sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredToken(): string | null {
|
||||
return readStoredSession()?.token ?? null;
|
||||
}
|
||||
@@ -239,26 +226,6 @@ let lastSessionReplacedEventAt = 0;
|
||||
|
||||
let lastSessionExpiredEventAt = 0;
|
||||
|
||||
function isNonAuthErrorCode(code: string | undefined): boolean {
|
||||
if (!code) return false;
|
||||
return [
|
||||
"ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED",
|
||||
"INSUFFICIENT_BALANCE",
|
||||
"INSUFFICIENT_ENTERPRISE_BALANCE",
|
||||
].includes(code);
|
||||
}
|
||||
|
||||
function isAuthFailureResponse(status: number, payload: unknown): boolean {
|
||||
if (status === 401) return true;
|
||||
if (status !== 403) return false;
|
||||
|
||||
const code = getPayloadCode(payload);
|
||||
if (code === "SESSION_REPLACED" || code === "TOKEN_EXPIRED" || code === "ACCOUNT_DISABLED") return true;
|
||||
|
||||
const message = getPayloadMessage(payload) || "";
|
||||
return /账号已禁用|登录已过期|登录状态|session|token|企业信息不存在/i.test(message);
|
||||
}
|
||||
|
||||
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
|
||||
if (status !== 401 && status !== 403) return;
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -271,10 +238,6 @@ function notifySessionExpired(status: number, response: Response, payload: unkno
|
||||
if (!readStoredSession()) return;
|
||||
// Deliberate early-exit for unauthenticated users — not a real auth failure.
|
||||
if (getPayloadCode(payload) === "NOT_LOGGED_IN") return;
|
||||
// Non-auth 403 errors (enterprise model access, insufficient balance) must
|
||||
// not trigger session expiry.
|
||||
if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return;
|
||||
if (!isAuthFailureResponse(status, payload)) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastSessionExpiredEventAt < 1500) return;
|
||||
@@ -358,10 +321,8 @@ const MAX_RETRIES = 2;
|
||||
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
|
||||
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 <= maxRetries; attempt++) {
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
const controller = timeoutMs > 0 ? new AbortController() : null;
|
||||
const timeoutId =
|
||||
controller && typeof window !== "undefined"
|
||||
@@ -380,14 +341,13 @@ export async function serverRequest<T>(path: string, options?: ServerRequestOpti
|
||||
headers,
|
||||
body: options?.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: controller ? controller.signal : options?.signal,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const payload = await readJsonResponse<unknown>(response, fallbackMessage);
|
||||
const payload = await readJsonResponse<unknown>(response, "Request failed");
|
||||
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < maxRetries && isRetryable(error) && !options?.signal?.aborted) {
|
||||
if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) {
|
||||
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { aiGenerationClient } from "./aiGenerationClient";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../utils/taskLifecycle";
|
||||
|
||||
export interface TaskProgressEvent {
|
||||
taskId: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
progressSource?: "real" | "estimated" | string | null;
|
||||
stage?: string | null;
|
||||
startedAt?: string | null;
|
||||
expectedDurationMs?: number | null;
|
||||
resultUrl?: string | null;
|
||||
error?: string | null;
|
||||
}
|
||||
@@ -21,37 +12,23 @@ export interface WaitForTaskOptions {
|
||||
onProgress?: (event: TaskProgressEvent) => void;
|
||||
abortRef?: { current: boolean };
|
||||
timeoutMs?: number;
|
||||
noProgressTimeoutMs?: number;
|
||||
startedAt?: number;
|
||||
kind?: "image" | "video" | "text";
|
||||
model?: string | null;
|
||||
operation?: string | null;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 3000;
|
||||
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
|
||||
|
||||
export function waitForTask(
|
||||
taskId: string,
|
||||
options: WaitForTaskOptions = {},
|
||||
): Promise<string | null> {
|
||||
const { onProgress, abortRef } = options;
|
||||
const timeoutPolicy = getTaskTimeoutPolicy({
|
||||
kind: options.kind,
|
||||
model: options.model,
|
||||
operation: options.operation,
|
||||
});
|
||||
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
|
||||
const noProgressTimeoutMs =
|
||||
options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
|
||||
const startedAt = options.startedAt ?? Date.now();
|
||||
const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let sseConnected = false;
|
||||
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastProgress = 0;
|
||||
let lastProgressAt = startedAt;
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
@@ -63,10 +40,7 @@ export function waitForTask(
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(
|
||||
() =>
|
||||
settle(() =>
|
||||
reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))),
|
||||
),
|
||||
() => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
@@ -76,23 +50,21 @@ export function waitForTask(
|
||||
settle(() => resolve(null));
|
||||
return;
|
||||
}
|
||||
const progress = Number(event.progress || 0);
|
||||
if (progress > lastProgress || event.status === "completed") {
|
||||
lastProgress = Math.max(lastProgress, progress);
|
||||
lastProgressAt = Date.now();
|
||||
}
|
||||
onProgress?.(event);
|
||||
if (event.status === "completed") {
|
||||
settle(() => resolve(event.resultUrl || null));
|
||||
} else if (event.status === "failed" || event.status === "cancelled") {
|
||||
settle(() => reject(new Error(event.error || "任务失败,请稍后重试")));
|
||||
settle(() => reject(new Error(event.error || "任务失败")));
|
||||
}
|
||||
};
|
||||
|
||||
// Try SSE first
|
||||
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
|
||||
sseConnected = true;
|
||||
|
||||
// Fallback: if SSE doesn't deliver any event within 5s, switch to polling
|
||||
fallbackTimerId = setTimeout(() => {
|
||||
if (settled) return;
|
||||
if (settled || !sseConnected) return;
|
||||
if (cleanup) cleanup();
|
||||
startPolling();
|
||||
}, 5000);
|
||||
@@ -100,36 +72,15 @@ export function waitForTask(
|
||||
function startPolling() {
|
||||
const poll = async () => {
|
||||
while (!settled) {
|
||||
if (abortRef?.current) {
|
||||
settle(() => resolve(null));
|
||||
return;
|
||||
}
|
||||
if (abortRef?.current) { settle(() => resolve(null)); return; }
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
||||
if (settled || abortRef?.current) return;
|
||||
const timeoutReason = isTaskLocallyTimedOut({
|
||||
startedAt,
|
||||
lastProgressAt,
|
||||
progress: lastProgress,
|
||||
policy: { ...timeoutPolicy, noProgressTimeoutMs },
|
||||
});
|
||||
if (timeoutReason) {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(buildLocalTimeoutMessage(options.kind || "video")),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const task = await aiGenerationClient.getTaskStatus(taskId);
|
||||
handleUpdate({
|
||||
taskId,
|
||||
status: task.status,
|
||||
progress: task.progress || 0,
|
||||
progressSource: task.progressSource,
|
||||
stage: task.stage,
|
||||
startedAt: task.startedAt,
|
||||
expectedDurationMs: task.expectedDurationMs,
|
||||
resultUrl: task.resultUrl,
|
||||
error: task.error,
|
||||
});
|
||||
@@ -138,7 +89,7 @@ export function waitForTask(
|
||||
}
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
poll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export const webGenerationGateway = {
|
||||
prompt,
|
||||
createdAt,
|
||||
source: "server",
|
||||
errorMessage: err instanceof Error ? err.message : "请求失败,请稍后重试",
|
||||
errorMessage: err instanceof Error ? err.message : "请求失败",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 5.5 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 706 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 5.5 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 7.6 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 5.5 MiB |
@@ -1,18 +1,28 @@
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
CheckCircleOutlined,
|
||||
FlagOutlined,
|
||||
InfoCircleOutlined,
|
||||
LoginOutlined,
|
||||
LogoutOutlined,
|
||||
PhoneOutlined,
|
||||
SafetyOutlined,
|
||||
EnvironmentOutlined,
|
||||
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";
|
||||
import { toast } from "./toast/toastStore";
|
||||
import type { ServerConnectionHealth } from "../api/serverConnection";
|
||||
import { ossAssets } from "../data/ossAssets";
|
||||
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
|
||||
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
|
||||
import NotificationCenter from "./NotificationCenter";
|
||||
import BetaApplicationModal from "./BetaApplicationModal";
|
||||
import { RechargeModal } from "./RechargeModal/RechargeModal";
|
||||
import { AnimatedPanel } from "./AnimatedPanel";
|
||||
import AdminMonitor from "./AdminMonitor";
|
||||
import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal";
|
||||
import { ShellIcon } from "./ShellIcon";
|
||||
import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme";
|
||||
import CookieConsentBanner from "./CookieConsentBanner";
|
||||
|
||||
interface AppShellProps {
|
||||
activeView: WebViewKey;
|
||||
@@ -30,51 +40,21 @@ interface AppShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BRAND_LOGO_URL = ossAssets.brand.logo;
|
||||
const TOOL_SURFACE_VIEW_SET = new Set<WebViewKey>([
|
||||
"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",
|
||||
];
|
||||
const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
|
||||
const CLIENT_ERROR_MONITOR_ENABLED = import.meta.env.VITE_ENABLE_CLIENT_ERROR_MONITOR === "1";
|
||||
|
||||
function formatBalance(cents: number): string {
|
||||
const value = Math.max(0, cents) / 100;
|
||||
return `${value.toFixed(2)} 积分`;
|
||||
}
|
||||
|
||||
function canReviewBetaApplications(session: WebUserSession | null): boolean {
|
||||
const role = String(session?.user.role || "").trim().toLowerCase();
|
||||
const username = String(session?.user.username || "").trim().toLowerCase();
|
||||
return role === "admin" || username === "xqy1912";
|
||||
}
|
||||
|
||||
function AppShell({
|
||||
activeView,
|
||||
navItems,
|
||||
session,
|
||||
usage,
|
||||
notifications,
|
||||
backendHealth,
|
||||
workspaceExpanded,
|
||||
onSelectView,
|
||||
onLogout,
|
||||
@@ -83,28 +63,50 @@ function AppShell({
|
||||
onMarkAllNotificationsRead,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
const activePackage = session?.user.activePackages?.[0];
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
const submenuHideTimerRef = useRef<number | null>(null);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [rechargeOpen, setRechargeOpen] = useState(false);
|
||||
const [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [betaOpen, setBetaOpen] = useState(false);
|
||||
const infoRef = useRef<HTMLDivElement>(null);
|
||||
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
|
||||
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
|
||||
const prevActiveViewRef = useRef<WebViewKey>(activeView);
|
||||
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
|
||||
const isAuthView = activeView === "login";
|
||||
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
|
||||
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
|
||||
const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView);
|
||||
const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home";
|
||||
const toolSurfaceViews = [
|
||||
"workbench",
|
||||
"canvas",
|
||||
"more",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"ecommerceTemplates",
|
||||
"sizeTemplate",
|
||||
"imageWorkbench",
|
||||
"resolutionUpscale",
|
||||
"digitalHuman",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
] as WebViewKey[];
|
||||
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
|
||||
|
||||
const visibleNavItems = useMemo(
|
||||
() => {
|
||||
const navItemByKey = new Map(navItems.map((item) => [item.key, item]));
|
||||
return PRIMARY_NAV_ORDER
|
||||
.map((key) => navItemByKey.get(key))
|
||||
const orderedKeys: WebViewKey[] = [
|
||||
"workbench",
|
||||
"ecommerce",
|
||||
"sizeTemplate",
|
||||
"canvas",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"community",
|
||||
"assets",
|
||||
"more",
|
||||
];
|
||||
return orderedKeys
|
||||
.map((key) => navItems.find((item) => item.key === key))
|
||||
.filter((item): item is WebNavItem => Boolean(item));
|
||||
},
|
||||
[navItems],
|
||||
@@ -124,7 +126,6 @@ function AppShell({
|
||||
return;
|
||||
}
|
||||
|
||||
void loadDarkGreenTheme();
|
||||
document.documentElement.dataset.theme = "dark";
|
||||
document.documentElement.dataset.uiTheme = "dark-green";
|
||||
document.documentElement.style.colorScheme = "dark";
|
||||
@@ -135,22 +136,6 @@ function AppShell({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
publicConfigClient
|
||||
.get()
|
||||
.then((config) => {
|
||||
if (!cancelled) setPublicConfig(config);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPublicConfig({});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileOpen) return;
|
||||
|
||||
@@ -189,21 +174,6 @@ 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);
|
||||
@@ -250,9 +220,9 @@ function AppShell({
|
||||
? (usage.enterpriseBalanceCents ?? session.user.enterpriseBalanceCents ?? usage.balanceCents)
|
||||
: usage.balanceCents;
|
||||
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
|
||||
const isPreviewSession = session?.source === "mock-fallback";
|
||||
const showCommunityReview = canReviewCommunity(session);
|
||||
const showCommunityCaseAdd = canManageCommunityCases(session);
|
||||
const showBetaApplicationReview = canReviewBetaApplications(session);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -325,7 +295,7 @@ function AppShell({
|
||||
aria-label="返回页面顶部"
|
||||
onClick={() => scrollActivePage("top")}
|
||||
>
|
||||
<ShellIcon name="arrow-up" />
|
||||
<ArrowUpOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -334,7 +304,7 @@ function AppShell({
|
||||
aria-label="到达页面底部"
|
||||
onClick={() => scrollActivePage("bottom")}
|
||||
>
|
||||
<ShellIcon name="arrow-down" />
|
||||
<ArrowDownOutlined />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -349,15 +319,6 @@ function AppShell({
|
||||
<span className="brand-lockup__name">OmniAI</span>
|
||||
</button>
|
||||
<div className="web-topbar__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="beta-apply-button"
|
||||
title="内测申请"
|
||||
aria-label="内测申请"
|
||||
onClick={() => setBetaOpen(true)}
|
||||
>
|
||||
内测申请
|
||||
</button>
|
||||
{session && (
|
||||
<NotificationCenter
|
||||
items={notifications}
|
||||
@@ -373,19 +334,18 @@ function AppShell({
|
||||
aria-label="网站信息"
|
||||
onClick={() => setInfoOpen((c) => !c)}
|
||||
>
|
||||
<ShellIcon name="info-circle" />
|
||||
<InfoCircleOutlined />
|
||||
</button>
|
||||
<AnimatedPanel open={infoOpen} className="info-popover panel-surface">
|
||||
<dl>
|
||||
<dt>备案信息</dt>
|
||||
<dd>{publicConfig.icpRecord || "由服务器配置"}</dd>
|
||||
<dd>苏ICP备2026021747号-1</dd>
|
||||
<dt>公司地址</dt>
|
||||
<dd>{publicConfig.companyAddress || "由服务器配置"}</dd>
|
||||
<dd>江苏省南京市江北新区扬子江数字视听产业园9栋A楼501</dd>
|
||||
<dt>联系电话</dt>
|
||||
<dd>{publicConfig.contactPhone || "由服务器配置"}</dd>
|
||||
<dd>15155073618</dd>
|
||||
</dl>
|
||||
<div className="info-popover__links">
|
||||
<a href="#/bug-feedback" onClick={() => setInfoOpen(false)}>Bug 反馈</a>
|
||||
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}>用户协议</a>
|
||||
<a href="#/privacyPolicy" onClick={() => setInfoOpen(false)}>隐私政策</a>
|
||||
</div>
|
||||
@@ -395,9 +355,9 @@ function AppShell({
|
||||
className="member-button"
|
||||
type="button"
|
||||
aria-label={`积分余额 ${displayedBalanceLabel}`}
|
||||
onClick={() => toast.info("充值功能即将开放,敬请期待")}
|
||||
onClick={() => setRechargeOpen(true)}
|
||||
>
|
||||
<ShellIcon name="wallet" />
|
||||
<WalletOutlined />
|
||||
<span className="member-button__label">{displayedBalanceLabel}</span>
|
||||
</button>
|
||||
<div className="profile-popover-anchor" ref={profileRef}>
|
||||
@@ -421,7 +381,7 @@ function AppShell({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShellIcon name="login" />
|
||||
<LoginOutlined />
|
||||
<span>登录 / 注册</span>
|
||||
</>
|
||||
)}
|
||||
@@ -447,9 +407,9 @@ function AppShell({
|
||||
<dd>{usage.videoUsed}</dd>
|
||||
</dl>
|
||||
<div className="profile-popover__footer">
|
||||
<span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
|
||||
<span>{import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"}</span>
|
||||
<button type="button" onClick={onLogout}>
|
||||
<ShellIcon name="logout" />
|
||||
<LogoutOutlined />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
@@ -461,7 +421,7 @@ function AppShell({
|
||||
onSelectView("login");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="user" />
|
||||
<UserOutlined />
|
||||
个人中心
|
||||
</button>
|
||||
<button
|
||||
@@ -472,8 +432,8 @@ function AppShell({
|
||||
onSelectView("report");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="flag" />
|
||||
Bug 反馈
|
||||
<FlagOutlined />
|
||||
投诉举报
|
||||
</button>
|
||||
{showCommunityReview ? (
|
||||
<>
|
||||
@@ -485,24 +445,11 @@ function AppShell({
|
||||
onSelectView("communityReview");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="check-circle" />
|
||||
<CheckCircleOutlined />
|
||||
社区审核
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{showBetaApplicationReview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__review-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("betaApplications");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="check-circle" />
|
||||
内测申请审核
|
||||
</button>
|
||||
) : null}
|
||||
{showCommunityCaseAdd ? (
|
||||
<>
|
||||
<button
|
||||
@@ -513,7 +460,7 @@ function AppShell({
|
||||
onSelectView("communityCaseAdd");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="plus-circle" />
|
||||
<PlusCircleOutlined />
|
||||
添加案例
|
||||
</button>
|
||||
</>
|
||||
@@ -526,11 +473,9 @@ function AppShell({
|
||||
<div className="web-shell__page">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||
{rechargeOpen && RechargeModal ? (
|
||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||
) : null}
|
||||
<BetaApplicationModal open={betaOpen} onClose={() => setBetaOpen(false)} />
|
||||
{CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||
<CookieConsentBanner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ interface BeforeAfterCompareProps {
|
||||
sourceAlt?: string;
|
||||
resultAlt?: string;
|
||||
className?: string;
|
||||
aspectRatio?: string;
|
||||
onSourceLoad?: (width: number, height: number) => void;
|
||||
}
|
||||
|
||||
@@ -27,7 +26,6 @@ export default function BeforeAfterCompare({
|
||||
sourceAlt = "原图",
|
||||
resultAlt = "结果",
|
||||
className = "",
|
||||
aspectRatio,
|
||||
onSourceLoad,
|
||||
}: BeforeAfterCompareProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
@@ -45,10 +43,7 @@ export default function BeforeAfterCompare({
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={`before-after-compare ${className}`}
|
||||
style={{
|
||||
"--compare-position": `${position}%`,
|
||||
...(aspectRatio ? { "--compare-aspect-ratio": aspectRatio } : {}),
|
||||
} as CSSProperties}
|
||||
style={{ "--compare-position": `${position}%` } as CSSProperties}
|
||||
aria-label="前后对比"
|
||||
>
|
||||
<div className="before-after-compare__layer before-after-compare__layer--source">
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
import { CloseOutlined, ExperimentOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { betaApplicationClient } from "../api/betaApplicationClient";
|
||||
|
||||
interface BetaApplicationModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/* ── Form state ── */
|
||||
interface BetaFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
wechat: string;
|
||||
industry: string;
|
||||
company: string;
|
||||
city: string;
|
||||
aiTools: string;
|
||||
aiDuration: string;
|
||||
aiTrack: string;
|
||||
aiDirection: string[];
|
||||
weeklyUsage: string;
|
||||
feedbackWilling: string;
|
||||
wantFeature: string[];
|
||||
selfStatement: string;
|
||||
signature: string;
|
||||
applicationDate: string;
|
||||
agreeRules: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_FORM: BetaFormData = {
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
wechat: "",
|
||||
industry: "",
|
||||
company: "",
|
||||
city: "",
|
||||
aiTools: "",
|
||||
aiDuration: "",
|
||||
aiTrack: "",
|
||||
aiDirection: [],
|
||||
weeklyUsage: "",
|
||||
feedbackWilling: "",
|
||||
wantFeature: [],
|
||||
selfStatement: "",
|
||||
signature: "",
|
||||
applicationDate: "",
|
||||
agreeRules: false,
|
||||
};
|
||||
|
||||
/* ── Option groups (from the docx) ── */
|
||||
const AI_DURATION_OPTIONS = ["1年以内", "1-3年", "3-5年", "5年以上"];
|
||||
const AI_TRACK_OPTIONS = ["是,长期承接相关业务", "业余创作", "新手学习"];
|
||||
const AI_DIRECTION_OPTIONS = [
|
||||
"AI短剧批量制作", "漫剧剧情生成", "自媒体短视频", "电商图文及视频素材",
|
||||
"MCN商业内容", "企业宣传视频", "个人兴趣创作", "其他",
|
||||
];
|
||||
const WEEKLY_USAGE_OPTIONS = ["7次及以上", "1-3次", "空闲时间使用"];
|
||||
const FEEDBACK_OPTIONS = ["全力配合深度反馈", "简单体验留言", "仅使用不反馈"];
|
||||
const WANT_FEATURE_OPTIONS = [
|
||||
"一站式短剧漫剧完整AIGC工作流", "电商素材自动化创作流程",
|
||||
"多模态智能中枢全能创作", "批量自动化创作流程", "全新未公开AI创作玩法",
|
||||
];
|
||||
|
||||
/* ── Helper: single-select radio group ── */
|
||||
function RadioGroup({
|
||||
name, options, value, onChange,
|
||||
}: {
|
||||
name: string;
|
||||
options: string[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="beta-radio-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="beta-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
checked={value === opt}
|
||||
onChange={() => onChange(opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helper: multi-select checkbox group ── */
|
||||
function CheckboxGroup({
|
||||
options, value, onChange,
|
||||
}: {
|
||||
options: string[];
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="beta-checkbox-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="beta-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.includes(opt)}
|
||||
onChange={() => {
|
||||
if (value.includes(opt)) {
|
||||
onChange(value.filter((item) => item !== opt));
|
||||
} else {
|
||||
onChange([...value, opt]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helper: text field ── */
|
||||
function TextField({
|
||||
label, value, onChange, placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="beta-text-field">
|
||||
<span className="beta-text-field__label">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="beta-text-field__input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? "请填写"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
|
||||
const [form, setForm] = useState<BetaFormData>(INITIAL_FORM);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const update = <K extends keyof BetaFormData>(key: K, value: BetaFormData[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (submitting) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
if (!form.name.trim()) return "请填写姓名 / 常用昵称";
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) return "请填写用于接收内测码的有效邮箱";
|
||||
if (!form.phone.trim()) return "请填写联系手机号码";
|
||||
if (!form.wechat.trim()) return "请填写微信账号";
|
||||
if (!form.selfStatement.trim()) return "请填写申请自述";
|
||||
if (!form.signature.trim()) return "请填写申请人确认签字";
|
||||
if (!form.applicationDate.trim()) return "请填写申请日期";
|
||||
if (!form.agreeRules) return "请先阅读并同意内测规则";
|
||||
return null;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return;
|
||||
const validationError = validate();
|
||||
if (validationError) {
|
||||
setMessage({ tone: "error", text: validationError });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await betaApplicationClient.submit({
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone.trim(),
|
||||
wechat: form.wechat.trim(),
|
||||
industry: form.industry.trim(),
|
||||
company: form.company.trim(),
|
||||
city: form.city.trim(),
|
||||
aiTools: form.aiTools.trim(),
|
||||
aiDuration: form.aiDuration.trim(),
|
||||
aiTrack: form.aiTrack.trim(),
|
||||
weeklyUsage: form.weeklyUsage.trim(),
|
||||
feedbackWilling: form.feedbackWilling.trim(),
|
||||
selfStatement: form.selfStatement.trim(),
|
||||
signature: form.signature.trim(),
|
||||
applicationDate: form.applicationDate.trim(),
|
||||
});
|
||||
setForm(INITIAL_FORM);
|
||||
setMessage({ tone: "success", text: "申请已提交,请留意预留邮箱中的审核结果。" });
|
||||
} catch (error) {
|
||||
setMessage({ tone: "error", text: error instanceof Error ? error.message : "提交内测申请失败" });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="beta-application-modal" role="dialog" aria-modal="true" aria-labelledby="beta-modal-title">
|
||||
<button type="button" className="beta-application-modal__backdrop" onClick={close} aria-label="关闭内测申请弹窗" />
|
||||
|
||||
<section className="beta-application-modal__panel">
|
||||
{/* ── Header ── */}
|
||||
<header className="beta-modal-header">
|
||||
<div className="beta-modal-header__left">
|
||||
<ExperimentOutlined className="beta-modal-header__icon" />
|
||||
<div>
|
||||
<h2 id="beta-modal-title">OmniAI 内测体验官申请表</h2>
|
||||
<p className="beta-modal-header__subtitle">封闭限量内测 · 仅限 <strong>30 人</strong> · 赠送 <strong>500 元等值 50,000 积分</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="beta-modal-header__close" onClick={close} aria-label="关闭" disabled={submitting}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* ── Body (scrollable document) ── */}
|
||||
<div className="beta-modal-body">
|
||||
|
||||
{/* 一、个人基础信息 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">一、个人基础信息</h3>
|
||||
<div className="beta-doc-grid">
|
||||
<TextField label="姓名 / 常用昵称" value={form.name} onChange={(v) => update("name", v)} />
|
||||
<TextField label="接收内测码邮箱" value={form.email} onChange={(v) => update("email", v)} placeholder="审核通过后内测码将发送到此邮箱" />
|
||||
<TextField label="联系手机号码" value={form.phone} onChange={(v) => update("phone", v)} />
|
||||
<TextField label="微信账号" value={form.wechat} onChange={(v) => update("wechat", v)} />
|
||||
<TextField label="所在行业 / 职业" value={form.industry} onChange={(v) => update("industry", v)} />
|
||||
<TextField label="所属公司 / 机构" value={form.company} onChange={(v) => update("company", v)} />
|
||||
<TextField label="所在城市" value={form.city} onChange={(v) => update("city", v)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 二、AI从业与使用经历 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">二、AI 从业与使用经历</h3>
|
||||
<div className="beta-doc-grid">
|
||||
<TextField label="日常常用 AI 创作工具有哪些" value={form.aiTools} onChange={(v) => update("aiTools", v)} placeholder="例如:Midjourney / Stable Diffusion / ChatGPT 等" />
|
||||
<div className="beta-form-group">
|
||||
<span className="beta-form-group__label">AI 内容创作从业时长</span>
|
||||
<RadioGroup name="aiDuration" options={AI_DURATION_OPTIONS} value={form.aiDuration} onChange={(v) => update("aiDuration", v)} />
|
||||
</div>
|
||||
<div className="beta-form-group">
|
||||
<span className="beta-form-group__label">是否深耕 AI 短剧、漫剧、数字视频、电商赛道</span>
|
||||
<RadioGroup name="aiTrack" options={AI_TRACK_OPTIONS} value={form.aiTrack} onChange={(v) => update("aiTrack", v)} />
|
||||
</div>
|
||||
<div className="beta-form-group beta-form-group--full">
|
||||
<span className="beta-form-group__label">日常主要创作方向(可多选)</span>
|
||||
<CheckboxGroup options={AI_DIRECTION_OPTIONS} value={form.aiDirection} onChange={(v) => update("aiDirection", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 三、内测使用意向调研 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">三、内测使用意向调研</h3>
|
||||
<div className="beta-doc-grid">
|
||||
<div className="beta-form-group">
|
||||
<span className="beta-form-group__label">每周可稳定登录使用内测平台次数</span>
|
||||
<RadioGroup name="weeklyUsage" options={WEEKLY_USAGE_OPTIONS} value={form.weeklyUsage} onChange={(v) => update("weeklyUsage", v)} />
|
||||
</div>
|
||||
<div className="beta-form-group">
|
||||
<span className="beta-form-group__label">是否愿意积极反馈产品 BUG、优化建议、功能需求</span>
|
||||
<RadioGroup name="feedback" options={FEEDBACK_OPTIONS} value={form.feedbackWilling} onChange={(v) => update("feedbackWilling", v)} />
|
||||
</div>
|
||||
<div className="beta-form-group beta-form-group--full">
|
||||
<span className="beta-form-group__label">本次最想体验 OmniAI 核心功能(可多选)</span>
|
||||
<CheckboxGroup options={WANT_FEATURE_OPTIONS} value={form.wantFeature} onChange={(v) => update("wantFeature", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 四、申请自述 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">四、申请自述 <em className="beta-required">(必填)</em></h3>
|
||||
<p className="beta-doc-section__desc">请简述自身 AI 创作优势、业务需求,以及加入本次封闭内测的理由:</p>
|
||||
<textarea
|
||||
className="beta-textarea"
|
||||
value={form.selfStatement}
|
||||
onChange={(e) => update("selfStatement", e.target.value)}
|
||||
placeholder="请在此填写您的申请自述(必填)…"
|
||||
rows={6}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 五、内测规则知情同意书 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">五、内测规则知情同意书</h3>
|
||||
<ol className="beta-rules-list">
|
||||
<li>本次为封闭限量内测,仅限 <strong>30 人</strong>,按照资质匹配度 + 申请顺序筛选;</li>
|
||||
<li>内测赠送 <strong>500 元等值 50,000 积分</strong>,仅限内测期间使用,不可提现、不可转让、不可兑换现金;</li>
|
||||
<li>内测版本含未上线测试功能,存在功能不稳定、界面调整、参数优化等情况,申请人自愿理解包容;</li>
|
||||
<li>严禁私自泄露内测专属工作流、内部功能、测试接口、未发布技术方案等内部资料;</li>
|
||||
<li>审核通过后,官方将在 <strong>48 小时</strong> 内通过预留邮箱发放内测码、登录权限及免费积分;</li>
|
||||
<li>正式版上线后,优质内测体验官可享受专属永久优惠权限与平台荣誉称号。</li>
|
||||
</ol>
|
||||
|
||||
<label className="beta-agree-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.agreeRules}
|
||||
onChange={(e) => update("agreeRules", e.target.checked)}
|
||||
/>
|
||||
<span>本人已完整阅读并同意以上全部内测规则,自愿遵守内测所有管理要求。</span>
|
||||
</label>
|
||||
|
||||
<div className="beta-doc-grid beta-doc-grid--two">
|
||||
<TextField label="申请人确认签字" value={form.signature} onChange={(v) => update("signature", v)} placeholder="请签署姓名" />
|
||||
<TextField label="申请填写日期" value={form.applicationDate} onChange={(v) => update("applicationDate", v)} placeholder="例如:2026年6月8日" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<footer className="beta-modal-footer">
|
||||
{message ? (
|
||||
<p className={`beta-modal-footer__message beta-modal-footer__message--${message.tone}`} role="status">
|
||||
{message.text}
|
||||
</p>
|
||||
) : null}
|
||||
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--secondary" onClick={close} disabled={submitting}>
|
||||
关闭
|
||||
</button>
|
||||
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--primary" onClick={() => void submit()} disabled={submitting}>
|
||||
{submitting ? "提交中..." : "提交申请"}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BetaApplicationModal;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
import "../styles/components/dropzone.css";
|
||||
|
||||
interface DropZoneProps {
|
||||
accept?: string;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "../styles/components/empty-state.css";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HomeOutlined } from "@ant-design/icons";
|
||||
import "../styles/pages/not-found.css";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface NotFoundPageProps {
|
||||
onGoHome: () => void;
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
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<WebNotificationType, React.ReactNode> = {
|
||||
task_completed: <ShellIcon name="check-circle" style={{ color: "#10b981" }} />,
|
||||
task_failed: <ShellIcon name="close-circle" style={{ color: "#ef4444" }} />,
|
||||
review_pending: <ShellIcon name="exclamation-circle" style={{ color: "#f59e0b" }} />,
|
||||
review_passed: <ShellIcon name="like" style={{ color: "#10b981" }} />,
|
||||
review_rejected: <ShellIcon name="dislike" style={{ color: "#f59e0b" }} />,
|
||||
credits_low: <ShellIcon name="exclamation-circle" style={{ color: "#f59e0b" }} />,
|
||||
session_expired: <ShellIcon name="lock" style={{ color: "#ef4444" }} />,
|
||||
info: <ShellIcon name="bell" style={{ color: "#2563eb" }} />,
|
||||
task_completed: <CheckCircleOutlined style={{ color: "#10b981" }} />,
|
||||
task_failed: <CloseCircleOutlined style={{ color: "#ef4444" }} />,
|
||||
review_pending: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
|
||||
review_passed: <LikeOutlined style={{ color: "#10b981" }} />,
|
||||
review_rejected: <DislikeOutlined style={{ color: "#f59e0b" }} />,
|
||||
credits_low: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
|
||||
session_expired: <LockOutlined style={{ color: "#ef4444" }} />,
|
||||
info: <BellOutlined style={{ color: "#2563eb" }} />,
|
||||
};
|
||||
|
||||
function parseTimestamp(dateStr: string): number {
|
||||
@@ -102,7 +111,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
|
||||
aria-label={`通知中心${unreadCount > 0 ? `,${unreadCount}条未读` : ""}`}
|
||||
onClick={() => { setOpen((v) => !v); setNow(Date.now()); }}
|
||||
>
|
||||
<ShellIcon name="bell" />
|
||||
<BellOutlined />
|
||||
{unreadCount > 0 && (
|
||||
<span className="notification-center__badge">{unreadCount > 99 ? "99+" : unreadCount}</span>
|
||||
)}
|
||||
@@ -118,7 +127,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
|
||||
)}
|
||||
{notifications.length > 0 && onClear && (
|
||||
<button className="notification-center__clear" type="button" onClick={() => { onClear(); setOpen(false); }}>
|
||||
<ShellIcon name="delete" /> 清空
|
||||
<DeleteOutlined /> 清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -126,7 +135,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl
|
||||
<div className="notification-center__list">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="notification-center__empty">
|
||||
<ShellIcon name="bell" style={{ fontSize: 28, opacity: 0.3 }} />
|
||||
<BellOutlined style={{ fontSize: 28, opacity: 0.3 }} />
|
||||
<span>暂无通知</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CloseOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import "../styles/components/onboarding.css";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
export type TourPhaseId = "chat" | "image" | "video";
|
||||
|
||||
interface TooltipStep {
|
||||
target: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Which side of the target to place the tooltip on (preferred). */
|
||||
placement?: "top" | "bottom" | "left" | "right";
|
||||
/** If true, this step requires the user to interact with the element to proceed. */
|
||||
interactive?: boolean;
|
||||
/** Shown as hint text when interactive. */
|
||||
actionHint?: string;
|
||||
}
|
||||
|
||||
interface TourPhase {
|
||||
id: TourPhaseId;
|
||||
label: string;
|
||||
steps: TooltipStep[];
|
||||
}
|
||||
|
||||
interface OnboardingTourProps {
|
||||
active: boolean;
|
||||
phase: TourPhaseId;
|
||||
stepIndex: number;
|
||||
onNext: (phase: TourPhaseId, stepIndex: number) => void;
|
||||
onSkip: (phase: TourPhaseId) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
// ─── Tour definitions ────────────────────────────────────────
|
||||
|
||||
const PHASES: Record<TourPhaseId, TourPhase> = {
|
||||
chat: {
|
||||
id: "chat",
|
||||
label: "对话模式",
|
||||
steps: [
|
||||
{
|
||||
target: "onboarding-chat-upload",
|
||||
title: "参考素材上传",
|
||||
description: "点击或拖拽上传图片、视频、音频等参考素材,帮助 AI 更好地理解你的需求。",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
target: "onboarding-chat-model",
|
||||
title: "AI 模型选择",
|
||||
description: "在这里选择对话使用的 AI 模型,不同模型有不同的擅长领域和风格。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-chat-speed",
|
||||
title: "思考速度",
|
||||
description: "「思考速度:高」回复更迅速简洁;「思考速度:急速」适合快速问答场景。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-chat-depth",
|
||||
title: "推理深度",
|
||||
description: "「推理深度:强」进行更深层逻辑推理;「推理深度:极限」适合复杂多步骤问题。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-chat-input",
|
||||
title: "提示词输入框",
|
||||
description: "在这里输入你的问题或创作需求,按 Enter 发送,Shift + Enter 换行。",
|
||||
placement: "top",
|
||||
},
|
||||
{
|
||||
target: "onboarding-mode-selector",
|
||||
title: "切换到图像生成模式",
|
||||
description: "点击「下一步」自动切换,或点击这个按钮手动选择「图像生成」进入下一阶段。",
|
||||
placement: "bottom",
|
||||
},
|
||||
],
|
||||
},
|
||||
image: {
|
||||
id: "image",
|
||||
label: "图像生成",
|
||||
steps: [
|
||||
{
|
||||
target: "onboarding-image-upload",
|
||||
title: "参考图上传",
|
||||
description: "上传参考图片,AI 将基于参考图的风格和内容生成新图像。支持 PNG / JPG / WebP。",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
target: "onboarding-image-model",
|
||||
title: "图像模型选择",
|
||||
description: "选择用于图像生成的 AI 模型,不同模型在风格、精度和速度上有所侧重。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-image-settings",
|
||||
title: "比例与分辨率",
|
||||
description: "设置生成图像的宽高比(如 16:9、1:1)和清晰度(1K/2K),根据使用场景选择。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-image-grid",
|
||||
title: "单图 / 多宫格模式",
|
||||
description: "「单图」生成一张完整图像;「多宫格」一次生成多张变体供你挑选最佳方案。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-image-input",
|
||||
title: "图像提示词",
|
||||
description: "描述你想要的图像内容、风格和细节,越具体效果越好。",
|
||||
placement: "top",
|
||||
},
|
||||
{
|
||||
target: "onboarding-mode-selector",
|
||||
title: "切换到视频生成模式",
|
||||
description: "点击「下一步」自动切换,或点击这个按钮手动选择「视频生成」进入下一阶段。",
|
||||
placement: "bottom",
|
||||
},
|
||||
],
|
||||
},
|
||||
video: {
|
||||
id: "video",
|
||||
label: "视频生成",
|
||||
steps: [
|
||||
{
|
||||
target: "onboarding-video-upload",
|
||||
title: "参考素材上传",
|
||||
description: "上传参考图片或视频片段,帮助 AI 确定视频的风格、色调和内容方向。",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-model",
|
||||
title: "视频模型选择",
|
||||
description: "选择视频生成模型。不同模型在画质、时长、运动流畅度上各有优势。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-frame",
|
||||
title: "生成方式:全能 / 首尾帧",
|
||||
description: "「全能参考」根据描述直接生成;「首尾帧」通过设定起始和结束画面精确控制转场。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-ratio",
|
||||
title: "视频画面比例",
|
||||
description: "选择画面比例。9:16 适合手机短视频(抖音/Reels),16:9 适合横屏展示。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-duration",
|
||||
title: "视频时长设置",
|
||||
description: "设置生成视频的秒数。时长越长,生成时间越久,建议从 5 秒开始尝试。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-quality",
|
||||
title: "分辨率与画质",
|
||||
description: "选择视频清晰度。720P 生成更快适合预览,1080P 画质更高适合最终成品。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-generate",
|
||||
title: "一切就绪,开始创作!",
|
||||
description: "设置完毕后,点击发送按钮(或按 Enter)即可开始你的首次视频生成。祝你创作愉快!",
|
||||
placement: "top",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Connector line calculation ──────────────────────────────
|
||||
|
||||
interface ConnectorPoints {
|
||||
x1: number; y1: number; // tooltip edge center
|
||||
x2: number; y2: number; // target edge center
|
||||
}
|
||||
|
||||
function calcConnector(
|
||||
tooltipRect: DOMRect,
|
||||
targetRect: DOMRect,
|
||||
placement: TooltipStep["placement"],
|
||||
): ConnectorPoints {
|
||||
const tx = targetRect.left + targetRect.width / 2;
|
||||
const ty = targetRect.top + targetRect.height / 2;
|
||||
const tcx = tooltipRect.left + tooltipRect.width / 2;
|
||||
const tcy = tooltipRect.top + tooltipRect.height / 2;
|
||||
|
||||
switch (placement) {
|
||||
case "top":
|
||||
return { x1: tcx, y1: tooltipRect.bottom, x2: tx, y2: targetRect.top };
|
||||
case "bottom":
|
||||
return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom };
|
||||
case "left":
|
||||
return { x1: tooltipRect.right, y1: tcy, x2: targetRect.left, y2: ty };
|
||||
case "right":
|
||||
return { x1: tooltipRect.left, y1: tcy, x2: targetRect.right, y2: ty };
|
||||
default:
|
||||
return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Placement engine ─────────────────────────────────────────
|
||||
|
||||
interface PlacementResult {
|
||||
left: number;
|
||||
top: number;
|
||||
actualPlacement: TooltipStep["placement"];
|
||||
}
|
||||
|
||||
/** Score a candidate — lower is better. Penalises covering the target or overflow. */
|
||||
function scorePlacement(
|
||||
left: number, top: number, tw: number, th: number,
|
||||
targetRect: DOMRect, vw: number, vh: number,
|
||||
): number {
|
||||
let score = 0;
|
||||
// Overflow penalty
|
||||
if (left < 0) score += Math.abs(left);
|
||||
if (top < 0) score += Math.abs(top);
|
||||
if (left + tw > vw) score += (left + tw - vw);
|
||||
if (top + th > vh) score += (top + th - vh);
|
||||
// Overlap with target penalty (avoid covering the highlighted element)
|
||||
const overlapX = Math.max(0, Math.min(left + tw, targetRect.right) - Math.max(left, targetRect.left));
|
||||
const overlapY = Math.max(0, Math.min(top + th, targetRect.bottom) - Math.max(top, targetRect.top));
|
||||
if (overlapX > 0 && overlapY > 0) score += overlapX * overlapY * 0.01;
|
||||
return score;
|
||||
}
|
||||
|
||||
function findBestPlacement(
|
||||
targetRect: DOMRect, tw: number, th: number,
|
||||
preferred: TooltipStep["placement"],
|
||||
): PlacementResult {
|
||||
const gap = 144;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const all: Array<TooltipStep["placement"]> = [
|
||||
preferred ?? "bottom",
|
||||
...(["bottom", "top", "right", "left"] as const).filter((p) => p !== (preferred ?? "bottom")),
|
||||
];
|
||||
|
||||
let best: PlacementResult = { left: 0, top: 0, actualPlacement: "bottom" };
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const p of all) {
|
||||
let left = 0, top = 0;
|
||||
switch (p) {
|
||||
case "bottom":
|
||||
left = targetRect.left + targetRect.width / 2 - tw / 2;
|
||||
top = targetRect.bottom + gap;
|
||||
break;
|
||||
case "top":
|
||||
left = targetRect.left + targetRect.width / 2 - tw / 2;
|
||||
top = targetRect.top - th - gap;
|
||||
break;
|
||||
case "right":
|
||||
left = targetRect.right + gap;
|
||||
top = targetRect.top + targetRect.height / 2 - th / 2;
|
||||
break;
|
||||
case "left":
|
||||
left = targetRect.left - tw - gap;
|
||||
top = targetRect.top + targetRect.height / 2 - th / 2;
|
||||
break;
|
||||
}
|
||||
left = Math.max(12, Math.min(left, vw - tw - 12));
|
||||
top = Math.max(12, Math.min(top, vh - th - 12));
|
||||
|
||||
const s = scorePlacement(left, top, tw, th, targetRect, vw, vh);
|
||||
if (s < bestScore) {
|
||||
bestScore = s;
|
||||
best = { left, top, actualPlacement: p };
|
||||
}
|
||||
if (s === 0) break; // perfect
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────
|
||||
|
||||
export default function OnboardingTour({
|
||||
active, phase, stepIndex, onNext, onSkip, onDone,
|
||||
}: OnboardingTourProps) {
|
||||
const [pos, setPos] = useState<PlacementResult>({ left: 0, top: 0, actualPlacement: "bottom" });
|
||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [connector, setConnector] = useState<ConnectorPoints | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const prevPhaseRef = useRef(phase);
|
||||
const prevStepRef = useRef(stepIndex);
|
||||
|
||||
const phaseDef = PHASES[phase];
|
||||
const currentStep = phaseDef?.steps[stepIndex];
|
||||
const totalSteps = phaseDef?.steps.length ?? 0;
|
||||
const isLastStep = stepIndex >= totalSteps - 1;
|
||||
const isVideoLastStep = phase === "video" && isLastStep;
|
||||
|
||||
const stepChanged = prevPhaseRef.current !== phase || prevStepRef.current !== stepIndex;
|
||||
prevPhaseRef.current = phase;
|
||||
prevStepRef.current = stepIndex;
|
||||
|
||||
const recalc = useCallback(() => {
|
||||
if (!currentStep) return;
|
||||
const el = document.querySelector(`[data-onboarding="${currentStep.target}"]`) as HTMLElement | null;
|
||||
if (!el) return; // Will be retried by the polling loop
|
||||
const rect = el.getBoundingClientRect();
|
||||
setTargetRect(rect);
|
||||
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!tooltip) return;
|
||||
const tr = tooltip.getBoundingClientRect();
|
||||
const best = findBestPlacement(rect, tr.width, tr.height, currentStep.placement);
|
||||
setPos(best);
|
||||
|
||||
// Recalculate tooltip rect after position update (use the same best pos)
|
||||
const virtualTooltipRect = new DOMRect(best.left, best.top, tr.width, tr.height);
|
||||
setConnector(calcConnector(virtualTooltipRect, rect, best.actualPlacement));
|
||||
}, [currentStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) { setVisible(false); return; }
|
||||
const t = setTimeout(() => { setVisible(true); recalc(); }, 120);
|
||||
return () => clearTimeout(t);
|
||||
}, [active, phase, stepIndex, recalc]);
|
||||
|
||||
// Reposition and retry when elements aren't ready
|
||||
useEffect(() => {
|
||||
if (!active || !visible) return;
|
||||
const h = () => recalc();
|
||||
window.addEventListener("resize", h);
|
||||
window.addEventListener("scroll", h, true);
|
||||
const obs = new MutationObserver(h);
|
||||
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
|
||||
|
||||
// Polling retry: keep looking for the target element if not found yet
|
||||
let retryId: number | null = null;
|
||||
let attempts = 0;
|
||||
const poll = () => {
|
||||
recalc();
|
||||
attempts += 1;
|
||||
if (attempts < 40) retryId = requestAnimationFrame(poll);
|
||||
};
|
||||
// Start polling after a short delay
|
||||
const startTimer = setTimeout(() => { poll(); }, 200);
|
||||
return () => {
|
||||
window.removeEventListener("resize", h);
|
||||
window.removeEventListener("scroll", h, true);
|
||||
obs.disconnect();
|
||||
clearTimeout(startTimer);
|
||||
if (retryId !== null) cancelAnimationFrame(retryId);
|
||||
};
|
||||
}, [active, visible, recalc]);
|
||||
|
||||
// Animate in on step change
|
||||
useEffect(() => {
|
||||
if (!active || !visible || !stepChanged) return;
|
||||
const el = tooltipRef.current;
|
||||
if (!el) return;
|
||||
el.classList.remove("onboarding-tooltip--pop");
|
||||
void el.offsetWidth; // force reflow
|
||||
el.classList.add("onboarding-tooltip--pop");
|
||||
}, [active, visible, stepChanged, phase, stepIndex]);
|
||||
|
||||
if (!active || !currentStep) return null;
|
||||
|
||||
const connectorPath = connector
|
||||
? `M ${connector.x1} ${connector.y1} L ${connector.x2} ${connector.y2}`
|
||||
: "";
|
||||
|
||||
const arrowAngle = connector
|
||||
? Math.atan2(connector.y2 - connector.y1, connector.x2 - connector.x1) * (180 / Math.PI)
|
||||
: 0;
|
||||
|
||||
const clipPath = targetRect
|
||||
? `polygon(0% 0%, 0% 100%, ${targetRect.left - 6}px 100%, ${targetRect.left - 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px 100%, 100% 100%, 100% 0%)`
|
||||
: "";
|
||||
|
||||
return createPortal(
|
||||
<div className={`onboarding-root${visible ? " is-visible" : ""}`} aria-label="新手引导教程">
|
||||
{/* Overlay */}
|
||||
<div className="onboarding-overlay" style={{ clipPath, WebkitClipPath: clipPath }} />
|
||||
|
||||
{/* Spotlight ring */}
|
||||
{targetRect && (
|
||||
<div
|
||||
className="onboarding-spotlight"
|
||||
style={{
|
||||
left: targetRect.left - 8,
|
||||
top: targetRect.top - 8,
|
||||
width: targetRect.width + 16,
|
||||
height: targetRect.height + 16,
|
||||
}}
|
||||
>
|
||||
{/* Animated pulse ring */}
|
||||
<div className="onboarding-spotlight__pulse" />
|
||||
<div className="onboarding-spotlight__pulse onboarding-spotlight__pulse--delay" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connector SVG line */}
|
||||
{connector && (
|
||||
<svg className="onboarding-connector" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="ob-conn-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="var(--accent, #00ff88)" stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor="var(--accent, #00ff88)" stopOpacity="0.9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Animated dash line */}
|
||||
<path
|
||||
d={connectorPath}
|
||||
fill="none"
|
||||
stroke="var(--accent, #00ff88)"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="8 4"
|
||||
strokeLinecap="round"
|
||||
opacity="0.7"
|
||||
className="onboarding-connector__path"
|
||||
/>
|
||||
{/* Arrow at target end */}
|
||||
<circle
|
||||
cx={connector.x2}
|
||||
cy={connector.y2}
|
||||
r="5"
|
||||
fill="var(--accent, #00ff88)"
|
||||
className="onboarding-connector__dot"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Tooltip card */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={`onboarding-tooltip onboarding-tooltip--${pos.actualPlacement}`}
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
role="dialog"
|
||||
aria-label={currentStep.title}
|
||||
>
|
||||
{/* Arrow pointing toward target */}
|
||||
<div
|
||||
className={`onboarding-tooltip__arrow onboarding-tooltip__arrow--${pos.actualPlacement}`}
|
||||
style={{ transform: `rotate(${arrowAngle}deg)` }}
|
||||
/>
|
||||
|
||||
<div className="onboarding-tooltip__head">
|
||||
<span className="onboarding-tooltip__phase-badge">{phaseDef.label}</span>
|
||||
<span className="onboarding-tooltip__counter">
|
||||
{stepIndex + 1} / {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<strong className="onboarding-tooltip__title">{currentStep.title}</strong>
|
||||
<p className="onboarding-tooltip__desc">{currentStep.description}</p>
|
||||
|
||||
<div className="onboarding-tooltip__actions">
|
||||
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost" onClick={onDone}>
|
||||
<CloseOutlined /> 跳过教程
|
||||
</button>
|
||||
{stepIndex > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost"
|
||||
onClick={() => onNext(phase, stepIndex - 1)}
|
||||
>
|
||||
<LeftOutlined /> 上一步
|
||||
</button>
|
||||
)}
|
||||
{isVideoLastStep ? (
|
||||
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={onDone}>
|
||||
开始使用 <RightOutlined />
|
||||
</button>
|
||||
) : isLastStep && phase !== "video" ? (
|
||||
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onSkip(phase)}>
|
||||
{phase === "chat" ? "进入图像生成" : "进入视频生成"} <RightOutlined />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onNext(phase, stepIndex + 1)}>
|
||||
下一步 <RightOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom progress bar */}
|
||||
<div className="onboarding-progress" aria-hidden="true">
|
||||
{(["chat", "image", "video"] as TourPhaseId[]).map((p) => (
|
||||
<div key={p} className="onboarding-progress__phase">
|
||||
<div
|
||||
className={`onboarding-progress__dot${p === phase ? " is-active" : ""}${
|
||||
(["chat", "image", "video"].indexOf(p) < ["chat", "image", "video"].indexOf(phase)) ? " is-done" : ""
|
||||
}`}
|
||||
/>
|
||||
<span>{PHASES[p].label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,6 @@ const NAV_ORDER: string[] = [
|
||||
"resolutionUpscale",
|
||||
"watermarkRemoval",
|
||||
"subtitleRemoval",
|
||||
"dialogGenerator",
|
||||
"digitalHuman",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
@@ -88,4 +87,4 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp
|
||||
{displayedChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -29,7 +28,7 @@ const membershipPlans: MembershipPlan[] = [
|
||||
subtitle: "Pro",
|
||||
period: "月付",
|
||||
price: "299 元 / 月",
|
||||
grant: "每月赠送 29900 积分,30 天有效",
|
||||
grant: "每月赠送 10000 积分,30 天有效",
|
||||
comparisonLabel: "专业版基础权益",
|
||||
icon: <CrownOutlined />,
|
||||
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
|
||||
@@ -41,7 +40,7 @@ const membershipPlans: MembershipPlan[] = [
|
||||
subtitle: "Pro",
|
||||
period: "季付",
|
||||
price: "897 元 / 季",
|
||||
grant: "季度合计 89700 积分,默认按月分摊",
|
||||
grant: "连续 3 个月按月发放 Pro 积分",
|
||||
comparisonLabel: "相比月付新增",
|
||||
badge: "季度",
|
||||
icon: <CrownOutlined />,
|
||||
@@ -54,7 +53,7 @@ const membershipPlans: MembershipPlan[] = [
|
||||
subtitle: "Pro",
|
||||
period: "年付",
|
||||
price: "1990 元 / 年",
|
||||
grant: "全年合计 199000 积分,默认按月分摊",
|
||||
grant: "全年合计 140000 积分,默认按月分摊",
|
||||
comparisonLabel: "相比季付新增",
|
||||
badge: "年费优惠",
|
||||
icon: <CrownOutlined />,
|
||||
@@ -67,7 +66,7 @@ const membershipPlans: MembershipPlan[] = [
|
||||
subtitle: "Enterprise",
|
||||
period: "月付",
|
||||
price: "499 元 / 月",
|
||||
grant: "每月赠送 49900 积分,30 天有效",
|
||||
grant: "每月赠送 2000 积分,30 天有效",
|
||||
comparisonLabel: "企业版基础权益",
|
||||
icon: <RocketOutlined />,
|
||||
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
|
||||
@@ -79,7 +78,7 @@ const membershipPlans: MembershipPlan[] = [
|
||||
subtitle: "Enterprise",
|
||||
period: "季付",
|
||||
price: "1497 元 / 季",
|
||||
grant: "季度合计 149700 积分,默认按月分摊",
|
||||
grant: "连续 3 个月按月发放企业版积分",
|
||||
comparisonLabel: "相比月付新增",
|
||||
badge: "季度",
|
||||
icon: <RocketOutlined />,
|
||||
@@ -92,7 +91,7 @@ const membershipPlans: MembershipPlan[] = [
|
||||
subtitle: "Enterprise",
|
||||
period: "年付",
|
||||
price: "4990 元 / 年",
|
||||
grant: "全年合计 499000 积分,默认按月分摊",
|
||||
grant: "全年合计 340000 积分,默认按月分摊",
|
||||
comparisonLabel: "相比季付新增",
|
||||
badge: "企业年费",
|
||||
icon: <RocketOutlined />,
|
||||
@@ -117,7 +116,7 @@ const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }>
|
||||
{ id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
|
||||
];
|
||||
|
||||
export interface RechargeModalProps {
|
||||
interface RechargeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentBalance?: number;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { RechargeModalProps } from "./RechargeModal";
|
||||
|
||||
export type RechargeModalComponent = ComponentType<RechargeModalProps>;
|
||||
|
||||
let rechargeModalPromise: Promise<RechargeModalComponent> | null = null;
|
||||
|
||||
export function loadRechargeModal(): Promise<RechargeModalComponent> {
|
||||
if (!rechargeModalPromise) {
|
||||
rechargeModalPromise = import("./RechargeModal").then((module) => module.RechargeModal);
|
||||
}
|
||||
|
||||
return rechargeModalPromise;
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
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 <path d="M12 5v14m0 0 6-6m-6 6-6-6" />;
|
||||
case "arrow-left":
|
||||
return <path d="M19 12H5m0 0 6-6m-6 6 6 6" />;
|
||||
case "arrow-up":
|
||||
return <path d="M12 19V5m0 0 6 6m-6-6-6 6" />;
|
||||
case "bar-chart":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 19V5" />
|
||||
<path d="M4 19h16" />
|
||||
<path d="M8 16v-5" />
|
||||
<path d="M12 16V8" />
|
||||
<path d="M16 16v-9" />
|
||||
</>
|
||||
);
|
||||
case "bell":
|
||||
return (
|
||||
<>
|
||||
<path d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
||||
<path d="M10 21h4" />
|
||||
</>
|
||||
);
|
||||
case "branches":
|
||||
return (
|
||||
<>
|
||||
<circle cx="6" cy="6" r="2" />
|
||||
<circle cx="18" cy="6" r="2" />
|
||||
<circle cx="12" cy="18" r="2" />
|
||||
<path d="M8 7.5 12 12l4-4.5" />
|
||||
<path d="M12 12v4" />
|
||||
</>
|
||||
);
|
||||
case "check-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8 12 2.5 2.5L16 9" />
|
||||
</>
|
||||
);
|
||||
case "chevron-left":
|
||||
return <path d="m15 18-6-6 6-6" />;
|
||||
case "chevron-right":
|
||||
return <path d="m9 18 6-6-6-6" />;
|
||||
case "close-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m9 9 6 6m0-6-6 6" />
|
||||
</>
|
||||
);
|
||||
case "copy":
|
||||
return (
|
||||
<>
|
||||
<rect x="8" y="8" width="11" height="11" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" />
|
||||
</>
|
||||
);
|
||||
case "customer-service":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 13a8 8 0 0 1 16 0" />
|
||||
<path d="M5 13h3v5H5a2 2 0 0 1-2-2v-1a2 2 0 0 1 2-2Z" />
|
||||
<path d="M16 13h3a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2h-3v-5Z" />
|
||||
<path d="M18 18c0 2-2 3-6 3" />
|
||||
</>
|
||||
);
|
||||
case "delete":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 7h16" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M6 7l1 14h10l1-14" />
|
||||
<path d="M9 7V4h6v3" />
|
||||
</>
|
||||
);
|
||||
case "download":
|
||||
return (
|
||||
<>
|
||||
<path d="M12 4v11" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 20h14" />
|
||||
</>
|
||||
);
|
||||
case "dislike":
|
||||
return (
|
||||
<>
|
||||
<path d="M7 3v12" />
|
||||
<path d="M7 15h9l-1 5a2 2 0 0 1-3 1l-3-6H5a2 2 0 0 1-2-2V6a3 3 0 0 1 3-3h1" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3" />
|
||||
</>
|
||||
);
|
||||
case "exclamation-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v6" />
|
||||
<path d="M12 17h.01" />
|
||||
</>
|
||||
);
|
||||
case "flag":
|
||||
return (
|
||||
<>
|
||||
<path d="M5 21V4" />
|
||||
<path d="M5 5h11l-1.5 4L16 13H5" />
|
||||
</>
|
||||
);
|
||||
case "file-text":
|
||||
return (
|
||||
<>
|
||||
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" />
|
||||
<path d="M14 3v6h6" />
|
||||
<path d="M8 13h8" />
|
||||
<path d="M8 17h6" />
|
||||
</>
|
||||
);
|
||||
case "folder":
|
||||
return <path d="M3 7h7l2 2h9v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />;
|
||||
case "global":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3c3 3 3 15 0 18" />
|
||||
<path d="M12 3c-3 3-3 15 0 18" />
|
||||
</>
|
||||
);
|
||||
case "heart":
|
||||
return <path d="M20 8.5c0 5-8 10.5-8 10.5S4 13.5 4 8.5A4.5 4.5 0 0 1 12 6a4.5 4.5 0 0 1 8 2.5Z" />;
|
||||
case "home":
|
||||
return (
|
||||
<>
|
||||
<path d="M3 11 12 4l9 7" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
<path d="M10 20v-6h4v6" />
|
||||
</>
|
||||
);
|
||||
case "info-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v6" />
|
||||
<path d="M12 7h.01" />
|
||||
</>
|
||||
);
|
||||
case "like":
|
||||
return (
|
||||
<>
|
||||
<path d="M7 21V9" />
|
||||
<path d="M7 9h3l3-6a2 2 0 0 1 3 1l-1 5h4a2 2 0 0 1 2 2l-2 8a3 3 0 0 1-3 2H7" />
|
||||
<path d="M3 10h4v10H3z" />
|
||||
</>
|
||||
);
|
||||
case "line-chart":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 19V5" />
|
||||
<path d="M4 19h16" />
|
||||
<path d="m7 15 4-4 3 3 5-7" />
|
||||
</>
|
||||
);
|
||||
case "lock":
|
||||
return (
|
||||
<>
|
||||
<rect x="5" y="10" width="14" height="10" rx="2" />
|
||||
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</>
|
||||
);
|
||||
case "login":
|
||||
return (
|
||||
<>
|
||||
<path d="M14 4h5v16h-5" />
|
||||
<path d="M4 12h10" />
|
||||
<path d="m10 8 4 4-4 4" />
|
||||
</>
|
||||
);
|
||||
case "logout":
|
||||
return (
|
||||
<>
|
||||
<path d="M10 4H5v16h5" />
|
||||
<path d="M20 12H10" />
|
||||
<path d="m14 8-4 4 4 4" />
|
||||
</>
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<>
|
||||
<path d="M12 3a9 9 0 1 1-8 5" />
|
||||
<path d="M4 3v5h5" />
|
||||
</>
|
||||
);
|
||||
case "plus-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 8v8" />
|
||||
<path d="M8 12h8" />
|
||||
</>
|
||||
);
|
||||
case "reload":
|
||||
return (
|
||||
<>
|
||||
<path d="M20 12a8 8 0 1 1-2.3-5.7" />
|
||||
<path d="M20 4v6h-6" />
|
||||
</>
|
||||
);
|
||||
case "robot":
|
||||
return (
|
||||
<>
|
||||
<rect x="5" y="8" width="14" height="11" rx="3" />
|
||||
<path d="M12 8V4" />
|
||||
<path d="M8 13h.01" />
|
||||
<path d="M16 13h.01" />
|
||||
<path d="M9 17h6" />
|
||||
</>
|
||||
);
|
||||
case "shopping":
|
||||
return (
|
||||
<>
|
||||
<path d="M6 7h15l-2 8H8L6 7Z" />
|
||||
<path d="M6 7 5 4H2" />
|
||||
<circle cx="9" cy="20" r="1.5" />
|
||||
<circle cx="18" cy="20" r="1.5" />
|
||||
</>
|
||||
);
|
||||
case "swap":
|
||||
return (
|
||||
<>
|
||||
<path d="M7 7h13m0 0-4-4m4 4-4 4" />
|
||||
<path d="M17 17H4m0 0 4-4m-4 4 4 4" />
|
||||
</>
|
||||
);
|
||||
case "team":
|
||||
return (
|
||||
<>
|
||||
<circle cx="9" cy="8" r="3" />
|
||||
<path d="M3 20a6 6 0 0 1 12 0" />
|
||||
<path d="M16 11a3 3 0 1 0-1-5.8" />
|
||||
<path d="M17 20a5 5 0 0 0-3-4.6" />
|
||||
</>
|
||||
);
|
||||
case "thunderbolt":
|
||||
return <path d="M13 2 4 14h7l-1 8 10-13h-7l1-7Z" />;
|
||||
case "tool":
|
||||
return <path d="M14.5 5.5a5 5 0 0 0 4 6.5L9 21l-6-6 9-9.5a5 5 0 0 0 2.5 0Z" />;
|
||||
case "upload":
|
||||
return (
|
||||
<>
|
||||
<path d="M12 20V9" />
|
||||
<path d="m7 14 5-5 5 5" />
|
||||
<path d="M5 4h14" />
|
||||
</>
|
||||
);
|
||||
case "user":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21a8 8 0 0 1 16 0" />
|
||||
</>
|
||||
);
|
||||
case "wallet":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 7h15a2 2 0 0 1 2 2v10H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h12" />
|
||||
<path d="M16 13h5" />
|
||||
<path d="M17 16h.01" />
|
||||
</>
|
||||
);
|
||||
case "warning":
|
||||
return (
|
||||
<>
|
||||
<path d="M12 3 2 20h20L12 3Z" />
|
||||
<path d="M12 9v5" />
|
||||
<path d="M12 17h.01" />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return <circle cx="12" cy="12" r="8" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function ShellIcon({ name, className, style }: ShellIconProps) {
|
||||
return (
|
||||
<span className={["anticon", "shell-icon", className].filter(Boolean).join(" ")} style={style} aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{renderIcon(name)}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import "../styles/components/skeleton.css";
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string | number;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "../styles/pages/studio-layout.css";
|
||||
|
||||
interface StudioToolLayoutProps {
|
||||
toolstrip?: ReactNode;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type ToastType = "success" | "error" | "info";
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
const OSS_PUBLIC_BASE_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com";
|
||||
|
||||
function oss(path: string): string {
|
||||
return `${OSS_PUBLIC_BASE_URL}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
function muban(path: string): string {
|
||||
return oss(`muban/${path.replace(/^\/+/, "")}`);
|
||||
}
|
||||
|
||||
function toolbox(path: string): string {
|
||||
return oss(`static/toolbox/${path.replace(/^\/+/, "")}`);
|
||||
}
|
||||
|
||||
export const ossAssets = {
|
||||
brand: {
|
||||
logo: oss("logo.png"),
|
||||
},
|
||||
auth: {
|
||||
showcaseVideo: oss("test5.mp4"),
|
||||
},
|
||||
home: {
|
||||
backgroundVideo: muban("hero-bg.mp4"),
|
||||
heroSlides: [oss("static/banners/light2_轮播1.jpg"), oss("static/banners/light2_轮播2.jpg"), oss("static/banners/light2_轮播3.jpg")],
|
||||
features: {
|
||||
ecommerce: muban("feature-ecommerce.jpg"),
|
||||
script: muban("feature-script.jpg"),
|
||||
token: muban("feature-token.jpg"),
|
||||
},
|
||||
},
|
||||
toolbox: {
|
||||
imageBefore: toolbox("%E7%89%9B%E4%BB%94.webp"),
|
||||
imageAfter: toolbox("%E8%A5%BF%E8%A3%85.webp"),
|
||||
watermarkBefore: toolbox("%E5%8E%BB%E6%B0%B4%E5%8D%B0%E5%89%8D.webp"),
|
||||
watermarkAfter: toolbox("%E5%8E%BB%E6%B0%B4%E5%8D%B0%E5%90%8E.webp"),
|
||||
},
|
||||
community: {
|
||||
cardImages: [
|
||||
muban("dianshang1.png"),
|
||||
muban("dianshang2.png"),
|
||||
muban("dianshang3.png"),
|
||||
muban("wechat-7.png"),
|
||||
muban("wechat-8.png"),
|
||||
muban("wechat-9.png"),
|
||||
],
|
||||
carouselVideos: [oss("test3.mp4"), oss("test4.mp4"), oss("test6.mp4")],
|
||||
},
|
||||
workflows: {
|
||||
caseImages: [
|
||||
muban("community/workflow-rain-night.jpg"),
|
||||
muban("community/workflow-character-look.jpg"),
|
||||
muban("community/workflow-skyline.jpg"),
|
||||
muban("community/workflow-lab.jpg"),
|
||||
],
|
||||
},
|
||||
ecommerce: {
|
||||
generated: muban("ecommerce-carousel-generated.png"),
|
||||
slides: {
|
||||
slide4: muban("slide-4.png"),
|
||||
slide5: muban("slide-5.png"),
|
||||
},
|
||||
heroSlides: [
|
||||
muban("ecommerce-hero-carousel/slide-1.webp"),
|
||||
muban("ecommerce-hero-carousel/slide-2.webp"),
|
||||
muban("ecommerce-hero-carousel/slide-3.webp"),
|
||||
muban("ecommerce-hero-carousel/slide-4.webp"),
|
||||
muban("ecommerce-hero-carousel/slide-5.webp"),
|
||||
],
|
||||
templateSlides: [
|
||||
muban("more-template-carousel/slide-1.jpg"),
|
||||
muban("more-template-carousel/slide-2.jpg"),
|
||||
muban("more-template-carousel/slide-3.jpg"),
|
||||
muban("more-template-carousel/slide-4.png"),
|
||||
muban("more-template-carousel/slide-5.gif"),
|
||||
],
|
||||
templateCases: [
|
||||
muban("ecommerce/templates/case-1.png"),
|
||||
muban("ecommerce/templates/case-2.png"),
|
||||
muban("ecommerce/templates/case-3.png"),
|
||||
muban("ecommerce/templates/case-4.png"),
|
||||
muban("ecommerce/templates/case-5.png"),
|
||||
muban("ecommerce/templates/case-6.png"),
|
||||
],
|
||||
productSet: {
|
||||
main: muban("ecommerce/product-set/main.webp"),
|
||||
scene: muban("ecommerce/product-set/scene.webp"),
|
||||
model: muban("ecommerce/product-set/model.webp"),
|
||||
detail: muban("ecommerce/product-set/detail.webp"),
|
||||
selling: muban("ecommerce/product-set/selling.webp"),
|
||||
hosting: muban("ecommerce/product-set/hosting.webp"),
|
||||
},
|
||||
tryOn: {
|
||||
dressA: muban("ecommerce/try-on/dress-a.webp"),
|
||||
dressB: muban("ecommerce/try-on/dress-b.webp"),
|
||||
modelWoman: muban("ecommerce/try-on/model-woman.webp"),
|
||||
modelMan: muban("ecommerce/try-on/model-man.webp"),
|
||||
modelAsian: muban("ecommerce/try-on/model-asian.webp"),
|
||||
tryA: muban("ecommerce/try-on/result-a.webp"),
|
||||
tryB: muban("ecommerce/try-on/result-b.webp"),
|
||||
jacket: muban("ecommerce/try-on/jacket.webp"),
|
||||
jacketResultA: muban("ecommerce/try-on/jacket-result-a.webp"),
|
||||
jacketResultB: muban("ecommerce/try-on/jacket-result-b.webp"),
|
||||
hat: muban("ecommerce/try-on/hat.webp"),
|
||||
hatResultA: muban("ecommerce/try-on/hat-result-a.webp"),
|
||||
hatResultB: muban("ecommerce/try-on/hat-result-b.webp"),
|
||||
},
|
||||
detail: {
|
||||
productA: muban("ecommerce/detail/product-a.webp"),
|
||||
productB: muban("ecommerce/detail/product-b.webp"),
|
||||
productC: muban("ecommerce/detail/product-c.webp"),
|
||||
longPage: muban("ecommerce/detail/long-page.webp"),
|
||||
gridA: muban("ecommerce/detail/grid-a.webp"),
|
||||
gridB: muban("ecommerce/detail/grid-b.webp"),
|
||||
gridC: muban("ecommerce/detail/grid-c.webp"),
|
||||
gridD: muban("ecommerce/detail/grid-d.webp"),
|
||||
gridE: muban("ecommerce/detail/grid-e.webp"),
|
||||
gridF: muban("ecommerce/detail/grid-f.webp"),
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ProductSetOssAssets = typeof ossAssets.ecommerce.productSet;
|
||||
export type TryOnOssAssets = typeof ossAssets.ecommerce.tryOn;
|
||||
export type DetailOssAssets = typeof ossAssets.ecommerce.detail;
|
||||
@@ -1,7 +1,4 @@
|
||||
import type { WebCanvasWorkflow, WebCommunityCase } from "../types";
|
||||
import { ossAssets } from "./ossAssets";
|
||||
|
||||
const [rainNightImage, characterLookImage, skylineImage, labImage] = ossAssets.workflows.caseImages;
|
||||
|
||||
function createNodes(
|
||||
title: string,
|
||||
@@ -72,7 +69,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "Dave",
|
||||
tag: "视频案例",
|
||||
summary: "从街口推到人物面部,强调雨夜反光与情绪收束。",
|
||||
imageUrl: rainNightImage,
|
||||
imageUrl: "https://picsum.photos/id/1011/900/540",
|
||||
workflow: {
|
||||
id: "workflow-rain-night",
|
||||
version: 1,
|
||||
@@ -86,7 +83,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "6s",
|
||||
resolution: "720p",
|
||||
},
|
||||
nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", rainNightImage),
|
||||
nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", "https://picsum.photos/id/1011/960/540"),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
@@ -96,7 +93,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "SuperXe",
|
||||
tag: "角色案例",
|
||||
summary: "把单张角色图扩展成可连续出片的角色工作流。",
|
||||
imageUrl: characterLookImage,
|
||||
imageUrl: "https://picsum.photos/id/1027/900/540",
|
||||
workflow: {
|
||||
id: "workflow-character-look",
|
||||
version: 1,
|
||||
@@ -110,7 +107,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "5s",
|
||||
resolution: "720p",
|
||||
},
|
||||
nodes: createNodes("角色定妆,强调服装、姿态与近景表情", characterLookImage),
|
||||
nodes: createNodes("角色定妆,强调服装、姿态与近景表情", "https://picsum.photos/id/1027/960/540"),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
@@ -120,7 +117,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "OmniAI",
|
||||
tag: "风景案例",
|
||||
summary: "用广角风景做镜头进入,适合转场和开场片头。",
|
||||
imageUrl: skylineImage,
|
||||
imageUrl: "https://picsum.photos/id/1050/900/540",
|
||||
workflow: {
|
||||
id: "workflow-skyline",
|
||||
version: 1,
|
||||
@@ -134,7 +131,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "8s",
|
||||
resolution: "1080p",
|
||||
},
|
||||
nodes: createNodes("风景开场,镜头缓慢推进到天际线", skylineImage),
|
||||
nodes: createNodes("风景开场,镜头缓慢推进到天际线", "https://picsum.photos/id/1050/960/540"),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
@@ -144,7 +141,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "Studio",
|
||||
tag: "实验案例",
|
||||
summary: "更适合拆解推拉摇移和节奏控制的实验模板。",
|
||||
imageUrl: labImage,
|
||||
imageUrl: "https://picsum.photos/id/1056/900/540",
|
||||
workflow: {
|
||||
id: "workflow-lab",
|
||||
version: 1,
|
||||
@@ -158,7 +155,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "6s",
|
||||
resolution: "720p",
|
||||
},
|
||||
nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", labImage),
|
||||
nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", "https://picsum.photos/id/1056/960/540"),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
SendOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import "../../styles/pages/agent.css";
|
||||
import { useRef, useState } from "react";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type { WebGenerationPreviewTask } from "../../types";
|
||||
|
||||
@@ -73,24 +72,6 @@ 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 {
|
||||
@@ -112,21 +93,6 @@ 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<string | null>(null);
|
||||
const controlsRef = useRef<HTMLDivElement>(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);
|
||||
@@ -237,85 +203,15 @@ function AgentPage({
|
||||
/>
|
||||
|
||||
<div className="agent-composer__footer">
|
||||
<div className="agent-composer__controls" aria-label="输入设置" ref={controlsRef}>
|
||||
<div className="agent-composer__controls" aria-label="输入设置">
|
||||
<button type="button" className="agent-tool-icon" aria-label="上传附件">
|
||||
<PaperClipOutlined />
|
||||
</button>
|
||||
<div className="agent-tool-pills">
|
||||
<button
|
||||
type="button"
|
||||
className={`agent-tool-pill${activeDropdown === "model" ? " is-open" : ""}`}
|
||||
onClick={() => setActiveDropdown(activeDropdown === "model" ? null : "model")}
|
||||
>
|
||||
<RobotOutlined />
|
||||
{agentModelOptions.find((m) => m.id === agentModel)?.label ?? "模型选择"}
|
||||
<DownOutlined />
|
||||
</button>
|
||||
{activeDropdown === "model" && (
|
||||
<div className="agent-dropdown">
|
||||
{agentModelOptions.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
className={`agent-dropdown__item${agentModel === m.id ? " is-active" : ""}`}
|
||||
onClick={() => { setAgentModel(m.id); setActiveDropdown(null); }}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="agent-tool-pills">
|
||||
<button
|
||||
type="button"
|
||||
className={`agent-tool-pill${activeDropdown === "speed" ? " is-open" : ""}`}
|
||||
onClick={() => setActiveDropdown(activeDropdown === "speed" ? null : "speed")}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
{thinkingSpeedOptions.find((s) => s.id === thinkingSpeed)?.label ?? "思考速度"}
|
||||
<DownOutlined />
|
||||
</button>
|
||||
{activeDropdown === "speed" && (
|
||||
<div className="agent-dropdown">
|
||||
{thinkingSpeedOptions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={`agent-dropdown__item${thinkingSpeed === s.id ? " is-active" : ""}`}
|
||||
onClick={() => { setThinkingSpeed(s.id); setActiveDropdown(null); }}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="agent-tool-pills">
|
||||
<button
|
||||
type="button"
|
||||
className={`agent-tool-pill${activeDropdown === "depth" ? " is-open" : ""}`}
|
||||
onClick={() => setActiveDropdown(activeDropdown === "depth" ? null : "depth")}
|
||||
>
|
||||
<ApartmentOutlined />
|
||||
{thinkingDepthOptions.find((d) => d.id === thinkingDepth)?.label ?? "思考深度"}
|
||||
<DownOutlined />
|
||||
</button>
|
||||
{activeDropdown === "depth" && (
|
||||
<div className="agent-dropdown">
|
||||
{thinkingDepthOptions.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={`agent-dropdown__item${thinkingDepth === d.id ? " is-active" : ""}`}
|
||||
onClick={() => { setThinkingDepth(d.id); setActiveDropdown(null); }}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="agent-tool-pill">
|
||||
<ThunderboltOutlined />
|
||||
自动模式
|
||||
<DownOutlined />
|
||||
</button>
|
||||
<button type="button" className="agent-tool-icon" aria-label="工具集">
|
||||
<AppstoreOutlined />
|
||||
</button>
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactElement } from "react";
|
||||
import "../../styles/pages/assets.css";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
|
||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { useDebounce } from "../../hooks/useDebounce";
|
||||
@@ -36,7 +35,7 @@ interface AssetsPageProps {
|
||||
onOpenLogin: () => void;
|
||||
}
|
||||
|
||||
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: ReactElement | null }> = [
|
||||
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: JSX.Element | null }> = [
|
||||
{ key: "all", label: "全部", icon: null },
|
||||
{ key: "character", label: "人物", icon: <UserOutlined /> },
|
||||
{ key: "scene", label: "场景", icon: <FileImageOutlined /> },
|
||||
@@ -95,17 +94,6 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploadDragging, setIsUploadDragging] = useState(false);
|
||||
|
||||
const handleUploadDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsUploadDragging(true); };
|
||||
const handleUploadDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsUploadDragging(false); };
|
||||
const handleUploadDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsUploadDragging(false);
|
||||
if (e.dataTransfer.files.length) {
|
||||
void handleUploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, asset: LibraryAssetItem) => {
|
||||
e.preventDefault();
|
||||
@@ -281,15 +269,7 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
placeholder="搜索资产..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={`studio-generate-btn studio-generate-btn--compact${isUploadDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
onDragOver={handleUploadDragOver}
|
||||
onDragLeave={handleUploadDragLeave}
|
||||
onDrop={handleUploadDrop}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<button type="button" className="studio-generate-btn studio-generate-btn--compact" onClick={() => uploadInputRef.current?.click()} disabled={isUploading}>
|
||||
{isUploading ? <LoadingOutlined /> : <PlusOutlined />}
|
||||
{isUploading ? "上传中..." : "添加"}
|
||||
</button>
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExperimentOutlined,
|
||||
FileSearchOutlined,
|
||||
LoginOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { betaApplicationClient, type BetaApplicationItem, type BetaApplicationStatus } from "../../api/betaApplicationClient";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type { WebUserSession } from "../../types";
|
||||
import "../../styles/pages/beta-applications.css";
|
||||
|
||||
interface BetaApplicationsPageProps {
|
||||
session: WebUserSession | null;
|
||||
onOpenLogin: () => void;
|
||||
}
|
||||
|
||||
type StatusFilter = BetaApplicationStatus | "";
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [
|
||||
{ value: "pending", label: "待审核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "已驳回" },
|
||||
{ value: "", label: "全部" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<BetaApplicationStatus, string> = {
|
||||
pending: "待审核",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
};
|
||||
|
||||
function canReviewBetaApplications(session: WebUserSession | null): boolean {
|
||||
const role = String(session?.user.role || "").trim().toLowerCase();
|
||||
const username = String(session?.user.username || "").trim().toLowerCase();
|
||||
return role === "admin" || username === "xqy1912";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "暂无时间";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function valueOrEmpty(value?: string | null): string {
|
||||
return value?.trim() || "未填写";
|
||||
}
|
||||
|
||||
function joinValues(values: string[]): string {
|
||||
return values.length ? values.join("、") : "未选择";
|
||||
}
|
||||
|
||||
function DetailField({ label, value, wide }: { label: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<div className={`beta-admin-field${wide ? " beta-admin-field--wide" : ""}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BetaApplicationsPage({ session, onOpenLogin }: BetaApplicationsPageProps) {
|
||||
const allowed = canReviewBetaApplications(session);
|
||||
const [status, setStatus] = useState<StatusFilter>("pending");
|
||||
const [applications, setApplications] = useState<BetaApplicationItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [reviewNote, setReviewNote] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedApplication = useMemo(
|
||||
() => applications.find((item) => item.id === selectedId) ?? applications[0] ?? null,
|
||||
[applications, selectedId],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!allowed) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const items = await betaApplicationClient.listAdminApplications(status);
|
||||
setApplications(items);
|
||||
setSelectedId((current) =>
|
||||
current && items.some((item) => item.id === current) ? current : (items[0]?.id ?? null),
|
||||
);
|
||||
} catch (loadError) {
|
||||
setApplications([]);
|
||||
setError(loadError instanceof Error ? loadError.message : "内测申请列表加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [allowed, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleDecision = async (action: "approve" | "reject") => {
|
||||
if (!selectedApplication || selectedApplication.status !== "pending" || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await betaApplicationClient.reviewApplication(selectedApplication.id, action, reviewNote.trim());
|
||||
setReviewNote("");
|
||||
await load();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "审核操作失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<section className="beta-admin-access">
|
||||
<LoginOutlined />
|
||||
<h1>请登录审核账号</h1>
|
||||
<p>内测申请审核仅开放给管理员和 xqy1912。</p>
|
||||
<button type="button" onClick={onOpenLogin}>登录 / 注册</button>
|
||||
</section>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<section className="beta-admin-access">
|
||||
<FileSearchOutlined />
|
||||
<h1>当前账号没有审核权限</h1>
|
||||
<p>请切换到 admin 或 xqy1912 后再进入内测审核台。</p>
|
||||
</section>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<div className="beta-admin-page__inner">
|
||||
<section className="beta-admin-toolbar">
|
||||
<div>
|
||||
<span>内部审核台</span>
|
||||
<h1>内测申请表</h1>
|
||||
<p>查看用户提交的完整申请资料,通过后发放内测码,驳回后向用户发送未通过通知。</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} disabled={loading}>
|
||||
<ReloadOutlined />
|
||||
刷新
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="beta-admin-status-tabs" role="tablist" aria-label="内测申请状态">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value || "all"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === option.value}
|
||||
className={status === option.value ? "is-active" : ""}
|
||||
onClick={() => setStatus(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <p className="beta-admin-error">{error}</p> : null}
|
||||
|
||||
<section className="beta-admin-layout">
|
||||
<aside className="beta-admin-list" aria-label="内测申请列表">
|
||||
{loading ? <div className="beta-admin-list__empty">正在加载申请...</div> : null}
|
||||
{!loading && applications.length === 0 ? (
|
||||
<div className="beta-admin-list__empty">暂无需要显示的申请</div>
|
||||
) : null}
|
||||
{applications.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`beta-admin-list__item${item.id === selectedApplication?.id ? " is-active" : ""}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<span className={`beta-admin-status beta-admin-status--${item.status}`}>{STATUS_LABEL[item.status]}</span>
|
||||
<strong>{item.name || item.username || `申请 #${item.id}`}</strong>
|
||||
<small>{item.industry || "未填写行业"} · {formatDate(item.createdAt)}</small>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{selectedApplication ? (
|
||||
<article className="beta-admin-detail">
|
||||
<header className="beta-admin-detail__header">
|
||||
<div>
|
||||
<span><ExperimentOutlined /> {STATUS_LABEL[selectedApplication.status]}</span>
|
||||
<h2>{selectedApplication.name || "未填写姓名"}</h2>
|
||||
<p>{selectedApplication.selfStatement || "申请人未填写自述。"}</p>
|
||||
</div>
|
||||
{selectedApplication.inviteCode ? (
|
||||
<strong className="beta-admin-code">内测码:{selectedApplication.inviteCode}</strong>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>一、个人基础信息</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="姓名 / 常用昵称" value={valueOrEmpty(selectedApplication.name)} />
|
||||
<DetailField label="接收内测码邮箱" value={valueOrEmpty(selectedApplication.email)} />
|
||||
<DetailField label="联系手机号码" value={valueOrEmpty(selectedApplication.phone)} />
|
||||
<DetailField label="微信账号" value={valueOrEmpty(selectedApplication.wechat)} />
|
||||
<DetailField label="所在行业 / 职业" value={valueOrEmpty(selectedApplication.industry)} />
|
||||
<DetailField label="所属公司 / 机构" value={valueOrEmpty(selectedApplication.company)} />
|
||||
<DetailField label="所在城市" value={valueOrEmpty(selectedApplication.city)} />
|
||||
<DetailField label="关联账号" value={selectedApplication.username || `UID ${selectedApplication.userId ?? "未登录提交"}`} />
|
||||
<DetailField label="提交时间" value={formatDate(selectedApplication.createdAt)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>二、AI 从业与使用经历</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="常用 AI 创作工具" value={valueOrEmpty(selectedApplication.aiTools)} wide />
|
||||
<DetailField label="AI 内容创作从业时长" value={valueOrEmpty(selectedApplication.aiDuration)} />
|
||||
<DetailField label="是否深耕相关赛道" value={valueOrEmpty(selectedApplication.aiTrack)} />
|
||||
<DetailField label="日常主要创作方向" value={joinValues(selectedApplication.aiDirection)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>三、内测使用意向调研</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="每周稳定使用次数" value={valueOrEmpty(selectedApplication.weeklyUsage)} />
|
||||
<DetailField label="反馈意愿" value={valueOrEmpty(selectedApplication.feedbackWilling)} />
|
||||
<DetailField label="最想体验功能" value={joinValues(selectedApplication.wantFeature)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>四、申请自述与确认</h3>
|
||||
<p className="beta-admin-statement">{selectedApplication.selfStatement || "未填写"}</p>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="申请人确认签字" value={valueOrEmpty(selectedApplication.signature)} />
|
||||
<DetailField label="申请填写日期" value={valueOrEmpty(selectedApplication.applicationDate)} />
|
||||
<DetailField label="同意规则" value={selectedApplication.agreeRules ? "已同意" : "未同意"} />
|
||||
<DetailField label="IP" value={valueOrEmpty(selectedApplication.ipAddress)} />
|
||||
<DetailField label="客户端" value={valueOrEmpty(selectedApplication.userAgent)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selectedApplication.status !== "pending" ? (
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>审核结果</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="审核人" value={selectedApplication.reviewerUsername || `UID ${selectedApplication.reviewedBy ?? "-"}`} />
|
||||
<DetailField label="审核时间" value={formatDate(selectedApplication.reviewedAt)} />
|
||||
<DetailField label="审核备注" value={valueOrEmpty(selectedApplication.reviewNote)} wide />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="beta-admin-review-box">
|
||||
<label>
|
||||
<span>审核备注</span>
|
||||
<textarea
|
||||
value={reviewNote}
|
||||
onChange={(event) => setReviewNote(event.target.value)}
|
||||
placeholder="填写通过说明或驳回原因;驳回时该备注会作为用户通知内容。"
|
||||
/>
|
||||
</label>
|
||||
<div className="beta-admin-actions">
|
||||
<button type="button" disabled={submitting} onClick={() => void handleDecision("reject")}>
|
||||
<CloseCircleOutlined />
|
||||
驳回并通知
|
||||
</button>
|
||||
<button type="button" disabled={submitting} onClick={() => void handleDecision("approve")}>
|
||||
<CheckCircleOutlined />
|
||||
通过并发放内测码
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
) : (
|
||||
<div className="beta-admin-detail beta-admin-detail--empty">选择左侧申请查看表单详情</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
interface CanvasMarkingPopoverProps {
|
||||
value?: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
onClear: () => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function CanvasMarkingPopover({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
onClear,
|
||||
onDone,
|
||||
}: CanvasMarkingPopoverProps) {
|
||||
return (
|
||||
<div
|
||||
className="studio-canvas-marking-popover"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<textarea
|
||||
className="studio-canvas-marking-input"
|
||||
placeholder={placeholder}
|
||||
value={value || ""}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
<div className="studio-canvas-marking-actions">
|
||||
{value ? (
|
||||
<button type="button" className="studio-canvas-marking-clear" onClick={onClear}>
|
||||
清除
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="studio-canvas-marking-done" onClick={onDone}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||
useSmoothedProgress,
|
||||
type ProgressSource,
|
||||
} from "../../hooks/useSmoothedProgress";
|
||||
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
|
||||
import { canvasGenerationProgressStyle } from "./canvasUtils";
|
||||
|
||||
type NodeGenStatus = "submitting" | "running" | "success" | "error";
|
||||
@@ -11,24 +7,10 @@ interface CanvasSmoothedProgressRingProps {
|
||||
progress: number;
|
||||
status: NodeGenStatus;
|
||||
message?: string;
|
||||
progressSource?: ProgressSource;
|
||||
startedAt?: number | string | Date | null;
|
||||
expectedDurationMs?: number | null;
|
||||
}
|
||||
|
||||
export function CanvasSmoothedProgressRing({
|
||||
progress,
|
||||
status,
|
||||
message,
|
||||
progressSource = "estimated",
|
||||
startedAt,
|
||||
expectedDurationMs = DEFAULT_GENERATION_EXPECTED_DURATION_MS,
|
||||
}: CanvasSmoothedProgressRingProps) {
|
||||
const smoothed = useSmoothedProgress(progress, status, {
|
||||
progressSource,
|
||||
startedAt,
|
||||
expectedDurationMs,
|
||||
});
|
||||
export function CanvasSmoothedProgressRing({ progress, status, message }: CanvasSmoothedProgressRingProps) {
|
||||
const smoothed = useSmoothedProgress(progress, status);
|
||||
return (
|
||||
<div
|
||||
className="studio-canvas-node-generation-progress"
|
||||
@@ -36,10 +18,7 @@ export function CanvasSmoothedProgressRing({
|
||||
aria-live="polite"
|
||||
style={canvasGenerationProgressStyle(smoothed)}
|
||||
>
|
||||
<span
|
||||
className="studio-canvas-node-generation-progress__ring"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="studio-canvas-node-generation-progress__ring" aria-hidden="true" />
|
||||
<strong>{message}</strong>
|
||||
<em>{smoothed}%</em>
|
||||
</div>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { SendOutlined } from "@ant-design/icons";
|
||||
import { useRef, type CSSProperties, type Dispatch, type SetStateAction } from "react";
|
||||
import type { CanvasNodeKind, CanvasPromptMentionOption, CanvasPromptMentionState } from "./canvasTypes";
|
||||
|
||||
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
|
||||
const EMPTY_MENTION_STYLE: CSSProperties = { opacity: 0.5, pointerEvents: "none" };
|
||||
const DEFAULT_MENTION_STATE: CanvasPromptMentionState = {
|
||||
open: false,
|
||||
query: "",
|
||||
start: 0,
|
||||
caret: 0,
|
||||
activeIndex: 0,
|
||||
};
|
||||
|
||||
interface CanvasPromptMentionTextareaProps {
|
||||
nodeId: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
mentionOptions: CanvasPromptMentionOption[];
|
||||
mentionState?: CanvasPromptMentionState;
|
||||
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
|
||||
onCloseMention: (nodeId: string) => void;
|
||||
onInsertMention: (
|
||||
nodeId: string,
|
||||
option: CanvasPromptMentionOption,
|
||||
textarea: HTMLTextAreaElement | null,
|
||||
kind?: CanvasNodeKind,
|
||||
) => void;
|
||||
mentionKind?: CanvasNodeKind;
|
||||
}
|
||||
|
||||
export function CanvasPromptMentionTextarea({
|
||||
nodeId,
|
||||
value,
|
||||
placeholder,
|
||||
mentionOptions,
|
||||
mentionState = DEFAULT_MENTION_STATE,
|
||||
onPromptChange,
|
||||
onMentionStateChange,
|
||||
onCloseMention,
|
||||
onInsertMention,
|
||||
mentionKind,
|
||||
}: CanvasPromptMentionTextareaProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const filteredMentions = mentionState.open
|
||||
? mentionOptions.filter((option) => !mentionState.query || option.searchText.includes(mentionState.query.toLowerCase()))
|
||||
: [];
|
||||
|
||||
const handlePromptChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = event.target.value;
|
||||
const caret = event.target.selectionStart || 0;
|
||||
onPromptChange(nodeId, value);
|
||||
|
||||
const beforeCaret = value.slice(0, caret);
|
||||
const atIndex = beforeCaret.lastIndexOf("@");
|
||||
if (atIndex >= 0) {
|
||||
const query = beforeCaret.slice(atIndex + 1);
|
||||
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
|
||||
onMentionStateChange((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: { open: true, query, start: atIndex, caret, activeIndex: 0 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onCloseMention(nodeId);
|
||||
};
|
||||
|
||||
const handlePromptKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!mentionState.open || filteredMentions.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
onMentionStateChange((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length },
|
||||
}));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
onMentionStateChange((prev) => ({
|
||||
...prev,
|
||||
[nodeId]: {
|
||||
...mentionState,
|
||||
activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length,
|
||||
},
|
||||
}));
|
||||
} else if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const option = filteredMentions[mentionState.activeIndex];
|
||||
if (option) {
|
||||
onInsertMention(nodeId, option, event.currentTarget, mentionKind);
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCloseMention(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptSelect = (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const caret = event.currentTarget.selectionStart || 0;
|
||||
onMentionStateChange((prev) => {
|
||||
const current = prev[nodeId];
|
||||
if (!current?.open) return prev;
|
||||
return { ...prev, [nodeId]: { ...current, caret } };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-canvas-text-composer__input-wrap">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onChange={handlePromptChange}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
onSelect={handlePromptSelect}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{mentionState.open ? (
|
||||
<div className="studio-canvas-mention-panel">
|
||||
{filteredMentions.length > 0 ? filteredMentions.map((option, index) => (
|
||||
<button
|
||||
key={option.token}
|
||||
type="button"
|
||||
className={`studio-canvas-mention-item${index === mentionState.activeIndex ? " is-active" : ""}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onInsertMention(nodeId, option, textareaRef.current, mentionKind);
|
||||
}}
|
||||
>
|
||||
<span className="studio-canvas-mention-thumb">
|
||||
{option.kind === "image" && option.previewUrl ? (
|
||||
<img src={option.previewUrl} alt="" />
|
||||
) : option.kind === "image" ? "🖼" : option.kind === "video" ? "🎬" : "📝"}
|
||||
</span>
|
||||
<span className="studio-canvas-mention-label">{option.nodeTitle}</span>
|
||||
<span className="studio-canvas-mention-token">{option.token}</span>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="studio-canvas-mention-item" style={EMPTY_MENTION_STYLE}>
|
||||
<span className="studio-canvas-mention-label">没有可引用的连接节点</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CanvasTextPromptComposerProps {
|
||||
nodeId: string;
|
||||
prompt: string;
|
||||
canGenerate: boolean;
|
||||
isGenerating: boolean;
|
||||
mentionOptions: CanvasPromptMentionOption[];
|
||||
mentionState?: CanvasPromptMentionState;
|
||||
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
|
||||
onCloseMention: (nodeId: string) => void;
|
||||
onInsertMention: (
|
||||
nodeId: string,
|
||||
option: CanvasPromptMentionOption,
|
||||
textarea: HTMLTextAreaElement | null,
|
||||
kind?: CanvasNodeKind,
|
||||
) => void;
|
||||
onGenerate: (nodeId: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function CanvasTextPromptComposer({
|
||||
nodeId,
|
||||
prompt,
|
||||
canGenerate,
|
||||
isGenerating,
|
||||
mentionOptions,
|
||||
mentionState,
|
||||
onPromptChange,
|
||||
onMentionStateChange,
|
||||
onCloseMention,
|
||||
onInsertMention,
|
||||
onGenerate,
|
||||
}: CanvasTextPromptComposerProps) {
|
||||
return (
|
||||
<div className="studio-canvas-text-composer">
|
||||
<CanvasPromptMentionTextarea
|
||||
nodeId={nodeId}
|
||||
value={prompt}
|
||||
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
|
||||
mentionOptions={mentionOptions}
|
||||
mentionState={mentionState}
|
||||
onPromptChange={onPromptChange}
|
||||
onMentionStateChange={onMentionStateChange}
|
||||
onCloseMention={onCloseMention}
|
||||
onInsertMention={onInsertMention}
|
||||
/>
|
||||
<div className="studio-canvas-text-composer__footer">
|
||||
<button
|
||||
type="button"
|
||||
className={`studio-canvas-text-composer__send studio-canvas-generate-button${canGenerate && !isGenerating ? " is-ready" : ""}`}
|
||||
title={isGenerating ? "生成中" : "生成"}
|
||||
disabled={isGenerating || !canGenerate}
|
||||
aria-busy={isGenerating}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isGenerating && canGenerate) {
|
||||
void onGenerate(nodeId);
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -106,11 +106,7 @@ function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Unable to read canvas result"));
|
||||
}
|
||||
typeof reader.result === "string" ? resolve(reader.result) : reject(new Error("Unable to read canvas result"));
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error("Unable to read canvas result"));
|
||||
reader.readAsDataURL(blob);
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
DownOutlined,
|
||||
ExpandOutlined,
|
||||
MutedOutlined,
|
||||
PauseCircleOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SoundOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type MouseEvent } from "react";
|
||||
import type { CanvasOption } from "./canvasTypes";
|
||||
|
||||
@@ -58,13 +58,13 @@ export const defaultTextModelId = textModelOptions[0].id;
|
||||
|
||||
// --- Image model options ---
|
||||
export const imageModelOptions: CanvasOption[] = [
|
||||
{ value: "wan2.7-image", label: "wan 2.7" },
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro" },
|
||||
{ value: "gpt-image-2", label: "omni-GPT" },
|
||||
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
|
||||
{ value: "nano-banana-pro", label: "omni-水果 Pro" },
|
||||
{ value: "nano-banana-2", label: "omni-水果 2" },
|
||||
{ value: "nano-banana-fast", label: "omni-水果" },
|
||||
{ value: "wan2.7-image", label: "wan 2.7 · 0.20 积分" },
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro · 0.20 积分" },
|
||||
{ value: "gpt-image-2", label: "GPT-Image-2 · 0.20 积分" },
|
||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP · 0.20 积分" },
|
||||
{ value: "nano-banana-pro", label: "Nano Banana Pro · 0.20 积分" },
|
||||
{ value: "nano-banana-2", label: "Nano Banana 2 · 0.20 积分" },
|
||||
{ value: "nano-banana-fast", label: "Nano Banana · 0.20 积分" },
|
||||
];
|
||||
|
||||
export const imageRatioOptions: CanvasOption[] = [
|
||||
@@ -140,6 +140,7 @@ export const videoRatioOptions: CanvasOption[] = [
|
||||
];
|
||||
|
||||
export const videoDurationOptions: CanvasOption[] = [
|
||||
{ value: "4", label: "4s" },
|
||||
{ value: "5", label: "5s" },
|
||||
{ value: "6", label: "6s" },
|
||||
{ value: "7", label: "7s" },
|
||||
|
||||