Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c113d82844 | |||
| 8cf9ee3519 | |||
| 2129b29dfe | |||
| d36d46836f | |||
| 91c332f567 | |||
| 5097b5ce49 | |||
| b17a978e9e | |||
| 93a7a6d5e6 | |||
| d7379af717 | |||
| 178a2c47da | |||
| d36a093159 | |||
| 8fbb2ec95e | |||
| 90e3b90e34 | |||
| 10b8379965 | |||
| c1c4086383 | |||
| 3493f169c0 | |||
| b81128d7ca | |||
| f0fed2f0fd | |||
| e166722945 | |||
| 6d68ab02bb | |||
| 2b65206b84 | |||
| 51762bb2c2 | |||
| e8a42dafde | |||
| c4ef9cc6ba | |||
| 05a42ed018 | |||
| 9e7bfdd206 | |||
| 20e219732d | |||
| c7c52c1467 | |||
| fb4011bf1f | |||
| b08a7918da | |||
| 7c6129555b | |||
| 6bb71fcc19 | |||
| 7993435704 | |||
| 31bf103d7c | |||
| bf401e4ab0 | |||
| 7e631cfa1b | |||
| ecade14bd0 |
@@ -1,8 +1,5 @@
|
||||
# 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=
|
||||
# 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.
|
||||
|
||||
@@ -10,6 +10,8 @@ node_modules/
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/
|
||||
tmp/
|
||||
*.swp
|
||||
*.swo
|
||||
coverage/
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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.
|
||||
@@ -8,6 +8,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit",
|
||||
"governance:check": "node scripts/check-governance.mjs",
|
||||
"style:check": "node scripts/check-style-governance.mjs",
|
||||
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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.");
|
||||
@@ -0,0 +1 @@
|
||||
import "./check-governance.mjs";
|
||||
@@ -0,0 +1,72 @@
|
||||
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, /buildApiUrl\("ai\/video"\)/);
|
||||
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
|
||||
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
|
||||
assertMatch(
|
||||
"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.");
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
SERVER_SESSION_REPLACED_EVENT,
|
||||
SERVER_SESSION_EXPIRED_EVENT,
|
||||
checkServerHealth,
|
||||
clearAllUserStorage,
|
||||
getErrorMessage,
|
||||
type ServerSessionReplacedDetail,
|
||||
} from "./api/serverConnection";
|
||||
@@ -47,6 +48,7 @@ const CommunityCaseAddPage = lazy(() => import("./features/community-review/Comm
|
||||
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
|
||||
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 HomePage = lazy(() => import("./features/home/HomePage"));
|
||||
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
|
||||
@@ -109,6 +111,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
||||
"resolutionUpscale",
|
||||
"watermarkRemoval",
|
||||
"subtitleRemoval",
|
||||
"dialogGenerator",
|
||||
"digitalHuman",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
@@ -122,7 +125,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
|
||||
"not-found",
|
||||
]);
|
||||
|
||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
|
||||
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
|
||||
|
||||
function normalizeViewKey(rawView: string): WebViewKey {
|
||||
const normalized =
|
||||
@@ -143,7 +146,9 @@ function normalizeViewKey(rawView: string): WebViewKey {
|
||||
}
|
||||
|
||||
function readViewFromHash(): WebViewKey {
|
||||
return normalizeViewKey(window.location.hash.replace(/^#\/?/, ""));
|
||||
const raw = window.location.hash.replace(/^#\/?/, "");
|
||||
if (!raw) return "home";
|
||||
return normalizeViewKey(raw);
|
||||
}
|
||||
|
||||
function isWorkspaceView(view: WebViewKey): boolean {
|
||||
@@ -375,7 +380,7 @@ function App() {
|
||||
}, [setView, setWorkspaceExpanded]);
|
||||
|
||||
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
|
||||
keyServerClient.clearSession();
|
||||
clearAllUserStorage();
|
||||
clearSessionState();
|
||||
setProjects([]);
|
||||
setProjectsLoaded(true);
|
||||
@@ -1156,6 +1161,8 @@ function App() {
|
||||
onSelectView={handleSetView}
|
||||
/>
|
||||
);
|
||||
case "dialogGenerator":
|
||||
return <DialogGeneratorPage />;
|
||||
case "report":
|
||||
return <ReportPage />;
|
||||
case "providerHealth":
|
||||
@@ -1224,7 +1231,7 @@ function App() {
|
||||
onMarkNotificationRead={handleMarkNotificationRead}
|
||||
onMarkAllNotificationsRead={handleMarkAllNotificationsRead}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<ErrorBoundary key={activeView}>
|
||||
<Suspense fallback={
|
||||
<div className="page-loading-center">
|
||||
<div className="page-loading-spinner" />
|
||||
@@ -1234,10 +1241,13 @@ function App() {
|
||||
<PageTransition viewKey={activeView}>
|
||||
{activePage}
|
||||
</PageTransition>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* KeepAlive: EcommercePage stays mounted once visited */}
|
||||
{/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */}
|
||||
{ecommerceEverMounted && (
|
||||
<div style={{ display: isEcommerceActive ? undefined : "none" }}>
|
||||
<div className="keepalive-ecommerce" style={{ display: isEcommerceActive ? undefined : "none" }}>
|
||||
<Suspense fallback={null}>
|
||||
<EcommercePage
|
||||
projects={projects}
|
||||
isAuthenticated={Boolean(session)}
|
||||
@@ -1250,10 +1260,9 @@ function App() {
|
||||
initialTemplate={pendingEcommerceTemplate}
|
||||
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
{loginPromptOpen && pendingAction ? (
|
||||
<div className="login-gate-modal" role="dialog" aria-modal="true" aria-labelledby="login-gate-title">
|
||||
|
||||
@@ -134,6 +134,12 @@ export interface ChatInput {
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
export interface ChatUsage {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
}
|
||||
|
||||
export interface AiTaskStatus {
|
||||
taskId: string;
|
||||
projectId?: string;
|
||||
@@ -159,7 +165,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" ? "视频生成任务" : "图像生成任务";
|
||||
return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1";
|
||||
}
|
||||
|
||||
function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask {
|
||||
@@ -500,6 +506,7 @@ 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",
|
||||
@@ -512,7 +519,7 @@ export const aiGenerationClient = {
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("无法读取响应流");
|
||||
if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
@@ -529,8 +536,24 @@ 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 };
|
||||
const chunk = JSON.parse(payload) as {
|
||||
delta?: string;
|
||||
done?: boolean;
|
||||
error?: string;
|
||||
usage?: ChatUsage & {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
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) {
|
||||
|
||||
@@ -913,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());
|
||||
@@ -1000,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)}`;
|
||||
|
||||
@@ -67,12 +67,11 @@ 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>(`config/profile?name=${encodeURIComponent(name)}`);
|
||||
payload = await serverRequest<unknown>(`public/config/profile?name=${encodeURIComponent(name)}`);
|
||||
} catch (error) {
|
||||
if (isOptionalApiRouteMissing(error)) {
|
||||
modelCapabilitiesRouteMissing = true;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -4,6 +4,8 @@ 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[];
|
||||
@@ -12,6 +14,33 @@ 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短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
|
||||
|
||||
【剧本类型识别】
|
||||
@@ -46,10 +75,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有
|
||||
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
|
||||
hook: { maxScore: 20 },
|
||||
plot: { maxScore: 20 },
|
||||
character: { maxScore: 18 },
|
||||
dialogue: { maxScore: 15 },
|
||||
character: { maxScore: 15 },
|
||||
logic: { maxScore: 15 },
|
||||
visual: { maxScore: 15 },
|
||||
content: { maxScore: 12 },
|
||||
content: { maxScore: 15 },
|
||||
};
|
||||
|
||||
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
|
||||
@@ -68,6 +97,48 @@ 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 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 = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3);
|
||||
if (items.length > 0) normalized[dimensionKey] = items;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
|
||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||
method: "POST",
|
||||
@@ -76,6 +147,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
|
||||
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,
|
||||
@@ -101,8 +173,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 val = Number(rawScores[key] ?? 0);
|
||||
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val));
|
||||
const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
|
||||
dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
|
||||
}
|
||||
|
||||
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
|
||||
@@ -111,6 +183,8 @@ 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,6 +1,5 @@
|
||||
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";
|
||||
@@ -59,34 +58,12 @@ export function compactMessage(value: string): string {
|
||||
}
|
||||
|
||||
export function getServerBaseUrl(): string {
|
||||
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(/^\/+/, "");
|
||||
const baseUrl = getServerBaseUrl();
|
||||
if (!baseUrl) return `/api/${cleanPath}`;
|
||||
|
||||
try {
|
||||
return new URL(`api/${cleanPath}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
|
||||
} catch {
|
||||
return `${baseUrl}/api/${cleanPath}`;
|
||||
}
|
||||
return `/api/${cleanPath}`;
|
||||
}
|
||||
|
||||
export function canUseSessionStorage(): boolean {
|
||||
@@ -167,6 +144,39 @@ 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;
|
||||
}
|
||||
@@ -226,6 +236,15 @@ 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 notifySessionExpired(status: number, response: Response, payload: unknown): void {
|
||||
if (status !== 401 && status !== 403) return;
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -238,6 +257,9 @@ 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;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastSessionExpiredEventAt < 1500) return;
|
||||
@@ -341,6 +363,7 @@ 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, "Request failed");
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { aiGenerationClient } from "./aiGenerationClient";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../utils/taskLifecycle";
|
||||
|
||||
export interface TaskProgressEvent {
|
||||
taskId: string;
|
||||
@@ -12,16 +17,28 @@ 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, timeoutMs = DEFAULT_TIMEOUT } = options;
|
||||
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();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -29,6 +46,8 @@ export function waitForTask(
|
||||
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;
|
||||
@@ -40,7 +59,7 @@ export function waitForTask(
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(
|
||||
() => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))),
|
||||
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
@@ -50,19 +69,22 @@ 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 || !sseConnected) return;
|
||||
if (cleanup) cleanup();
|
||||
@@ -72,9 +94,22 @@ 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({
|
||||
@@ -89,7 +124,7 @@ export function waitForTask(
|
||||
}
|
||||
}
|
||||
};
|
||||
poll();
|
||||
void poll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export const webGenerationGateway = {
|
||||
prompt,
|
||||
createdAt,
|
||||
source: "server",
|
||||
errorMessage: err instanceof Error ? err.message : "请求失败",
|
||||
errorMessage: err instanceof Error ? err.message : "请求失败,请稍后重试",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 5.5 MiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 5.5 MiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 7.6 MiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 5.5 MiB |
@@ -6,16 +6,16 @@ import {
|
||||
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";
|
||||
@@ -40,8 +40,7 @@ interface AppShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
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";
|
||||
const BRAND_LOGO_URL = ossAssets.brand.logo;
|
||||
|
||||
function formatBalance(cents: number): string {
|
||||
const value = Math.max(0, cents) / 100;
|
||||
@@ -71,6 +70,7 @@ function AppShell({
|
||||
const [infoOpen, setInfoOpen] = 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";
|
||||
@@ -87,6 +87,7 @@ function AppShell({
|
||||
"imageWorkbench",
|
||||
"resolutionUpscale",
|
||||
"digitalHuman",
|
||||
"dialogGenerator",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
] as WebViewKey[];
|
||||
@@ -136,6 +137,22 @@ 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;
|
||||
|
||||
@@ -220,7 +237,6 @@ 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);
|
||||
|
||||
@@ -339,11 +355,11 @@ function AppShell({
|
||||
<AnimatedPanel open={infoOpen} className="info-popover panel-surface">
|
||||
<dl>
|
||||
<dt>备案信息</dt>
|
||||
<dd>苏ICP备2026021747号-1</dd>
|
||||
<dd>{publicConfig.icpRecord || "由服务器配置"}</dd>
|
||||
<dt>公司地址</dt>
|
||||
<dd>江苏省南京市江北新区扬子江数字视听产业园9栋A楼501</dd>
|
||||
<dd>{publicConfig.companyAddress || "由服务器配置"}</dd>
|
||||
<dt>联系电话</dt>
|
||||
<dd>15155073618</dd>
|
||||
<dd>{publicConfig.contactPhone || "由服务器配置"}</dd>
|
||||
</dl>
|
||||
<div className="info-popover__links">
|
||||
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}>用户协议</a>
|
||||
@@ -355,7 +371,7 @@ function AppShell({
|
||||
className="member-button"
|
||||
type="button"
|
||||
aria-label={`积分余额 ${displayedBalanceLabel}`}
|
||||
onClick={() => setRechargeOpen(true)}
|
||||
onClick={() => toast.info("充值功能即将开放,敬请期待")}
|
||||
>
|
||||
<WalletOutlined />
|
||||
<span className="member-button__label">{displayedBalanceLabel}</span>
|
||||
@@ -407,7 +423,7 @@ function AppShell({
|
||||
<dd>{usage.videoUsed}</dd>
|
||||
</dl>
|
||||
<div className="profile-popover__footer">
|
||||
<span>{import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"}</span>
|
||||
<span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
|
||||
<button type="button" onClick={onLogout}>
|
||||
<LogoutOutlined />
|
||||
退出
|
||||
@@ -473,7 +489,7 @@ function AppShell({
|
||||
<div className="web-shell__page">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
{CLIENT_ERROR_MONITOR_ENABLED && session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||
<CookieConsentBanner />
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ const NAV_ORDER: string[] = [
|
||||
"resolutionUpscale",
|
||||
"watermarkRemoval",
|
||||
"subtitleRemoval",
|
||||
"dialogGenerator",
|
||||
"digitalHuman",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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,4 +1,7 @@
|
||||
import type { WebCanvasWorkflow, WebCommunityCase } from "../types";
|
||||
import { ossAssets } from "./ossAssets";
|
||||
|
||||
const [rainNightImage, characterLookImage, skylineImage, labImage] = ossAssets.workflows.caseImages;
|
||||
|
||||
function createNodes(
|
||||
title: string,
|
||||
@@ -69,7 +72,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "Dave",
|
||||
tag: "视频案例",
|
||||
summary: "从街口推到人物面部,强调雨夜反光与情绪收束。",
|
||||
imageUrl: "https://picsum.photos/id/1011/900/540",
|
||||
imageUrl: rainNightImage,
|
||||
workflow: {
|
||||
id: "workflow-rain-night",
|
||||
version: 1,
|
||||
@@ -83,7 +86,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "6s",
|
||||
resolution: "720p",
|
||||
},
|
||||
nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", "https://picsum.photos/id/1011/960/540"),
|
||||
nodes: createNodes("雨夜街巷,镜头从水面倒影推进到人物特写", rainNightImage),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
@@ -93,7 +96,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "SuperXe",
|
||||
tag: "角色案例",
|
||||
summary: "把单张角色图扩展成可连续出片的角色工作流。",
|
||||
imageUrl: "https://picsum.photos/id/1027/900/540",
|
||||
imageUrl: characterLookImage,
|
||||
workflow: {
|
||||
id: "workflow-character-look",
|
||||
version: 1,
|
||||
@@ -107,7 +110,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "5s",
|
||||
resolution: "720p",
|
||||
},
|
||||
nodes: createNodes("角色定妆,强调服装、姿态与近景表情", "https://picsum.photos/id/1027/960/540"),
|
||||
nodes: createNodes("角色定妆,强调服装、姿态与近景表情", characterLookImage),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
@@ -117,7 +120,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "OmniAI",
|
||||
tag: "风景案例",
|
||||
summary: "用广角风景做镜头进入,适合转场和开场片头。",
|
||||
imageUrl: "https://picsum.photos/id/1050/900/540",
|
||||
imageUrl: skylineImage,
|
||||
workflow: {
|
||||
id: "workflow-skyline",
|
||||
version: 1,
|
||||
@@ -131,7 +134,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "8s",
|
||||
resolution: "1080p",
|
||||
},
|
||||
nodes: createNodes("风景开场,镜头缓慢推进到天际线", "https://picsum.photos/id/1050/960/540"),
|
||||
nodes: createNodes("风景开场,镜头缓慢推进到天际线", skylineImage),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
@@ -141,7 +144,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
author: "Studio",
|
||||
tag: "实验案例",
|
||||
summary: "更适合拆解推拉摇移和节奏控制的实验模板。",
|
||||
imageUrl: "https://picsum.photos/id/1056/900/540",
|
||||
imageUrl: labImage,
|
||||
workflow: {
|
||||
id: "workflow-lab",
|
||||
version: 1,
|
||||
@@ -155,7 +158,7 @@ export const communityCases: WebCommunityCase[] = [
|
||||
duration: "6s",
|
||||
resolution: "720p",
|
||||
},
|
||||
nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", "https://picsum.photos/id/1056/960/540"),
|
||||
nodes: createNodes("镜头实验,分镜更清晰,便于二次调整", labImage),
|
||||
edges: createEdges(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
Background,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
|
||||
@@ -183,6 +182,7 @@ import {
|
||||
} from "./canvasWorkflowDeserialize";
|
||||
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
|
||||
import type { CanvasNodeToolbarAction } from "./canvasComponents";
|
||||
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
|
||||
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
|
||||
|
||||
const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
|
||||
@@ -337,6 +337,7 @@ function CanvasPage({
|
||||
const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
|
||||
const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
|
||||
const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null);
|
||||
const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null);
|
||||
const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null);
|
||||
const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
|
||||
const [stylePickerLoading, setStylePickerLoading] = useState(false);
|
||||
@@ -2646,6 +2647,22 @@ function CanvasPage({
|
||||
}
|
||||
: null;
|
||||
})()
|
||||
: connectionDropMenu
|
||||
? (() => {
|
||||
const source = getNodePortPoint(connectionDropMenu.sourcePort);
|
||||
const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop);
|
||||
return source
|
||||
? {
|
||||
id: "pending-link-preview",
|
||||
sourceX: source.x,
|
||||
sourceY: source.y,
|
||||
targetX: target.x,
|
||||
targetY: target.y,
|
||||
sourceSide: connectionDropMenu.sourcePort.side,
|
||||
targetSide: null,
|
||||
}
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => {
|
||||
@@ -2809,13 +2826,15 @@ function CanvasPage({
|
||||
if (targetPort) {
|
||||
connectCanvasPorts(connectorDrag.port, targetPort);
|
||||
} else {
|
||||
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0);
|
||||
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40);
|
||||
setConnectionDropMenu({
|
||||
...menuPosition,
|
||||
originLeft: event.clientX,
|
||||
originTop: event.clientY,
|
||||
sourcePort: connectorDrag.port,
|
||||
});
|
||||
setPendingLinkPort(null);
|
||||
setPendingLinkPreviewPoint(null);
|
||||
}
|
||||
} else {
|
||||
clearPendingConnector();
|
||||
@@ -2840,7 +2859,7 @@ function CanvasPage({
|
||||
}, [selectedNode]);
|
||||
|
||||
const handleCanvasMouseMove = (event: MouseEvent<HTMLElement>) => {
|
||||
if (!pendingLinkPort) return;
|
||||
if (!pendingLinkPort || connectionDropMenu) return;
|
||||
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
|
||||
};
|
||||
|
||||
@@ -3542,7 +3561,8 @@ function CanvasPage({
|
||||
onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
|
||||
onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
|
||||
style={{
|
||||
"--canvas-bg-size": `${24 * canvasViewport.zoom}px`,
|
||||
"--canvas-bg-size": `${34 * canvasViewport.zoom}px`,
|
||||
"--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`,
|
||||
"--canvas-bg-x": `${canvasViewport.x}px`,
|
||||
"--canvas-bg-y": `${canvasViewport.y}px`,
|
||||
cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined,
|
||||
@@ -3730,16 +3750,14 @@ function CanvasPage({
|
||||
proOptions={{ hideAttribution: true }}
|
||||
onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
|
||||
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
|
||||
>
|
||||
<Background gap={24} color="transparent" className="studio-canvas__background" />
|
||||
</ReactFlow>
|
||||
/>
|
||||
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button type="button" title="缩小" onClick={zoomCanvasOut}>−</button>
|
||||
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
|
||||
<button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}>−</button>
|
||||
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
|
||||
{Math.round(canvasViewport.zoom * 100)}%
|
||||
</button>
|
||||
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
|
||||
<button type="button" title="适应视图" onClick={fitCanvasView}>⊡</button>
|
||||
<button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
|
||||
<button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}>⊡</button>
|
||||
</div>
|
||||
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
|
||||
<div
|
||||
@@ -4248,7 +4266,7 @@ function CanvasPage({
|
||||
setSelectedExistingCategory("");
|
||||
setSaveAssetOpen(true);
|
||||
}
|
||||
if (key === "upscale") void handleGenerateImageNode(imageNode.id);
|
||||
if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode });
|
||||
}}
|
||||
moreActions={[
|
||||
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
|
||||
@@ -4554,16 +4572,42 @@ function CanvasPage({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={imageNodeFocusActive ? "is-active" : ""}
|
||||
title="框选聚焦区域"
|
||||
title="多宫格生成"
|
||||
disabled={!imageNode.imageUrl}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openImageFocusMode(imageNode);
|
||||
setCanvasToolModal({ tool: "multiGrid", imageNode });
|
||||
}}
|
||||
>
|
||||
<BarsOutlined /><span>聚焦</span>
|
||||
<BarsOutlined /><span>多宫格</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="图片超分辨率"
|
||||
disabled={!imageNode.imageUrl}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCanvasToolModal({ tool: "upscale", imageNode });
|
||||
}}
|
||||
>
|
||||
<ThunderboltOutlined /><span>超分</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="局部重绘"
|
||||
disabled={!imageNode.imageUrl}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCanvasToolModal({ tool: "inpaint", imageNode });
|
||||
}}
|
||||
>
|
||||
<EditOutlined /><span>局部重绘</span>
|
||||
</button>
|
||||
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开">↗</button>
|
||||
</div>
|
||||
@@ -5534,11 +5578,6 @@ function CanvasPage({
|
||||
role="menu"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onMouseMove={(event) => {
|
||||
if (pendingLinkPort) {
|
||||
setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="studio-canvas-add-node-menu__title">新建节点并连接</div>
|
||||
<button
|
||||
@@ -5718,6 +5757,27 @@ function CanvasPage({
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{canvasToolModal && (
|
||||
<div className="studio-canvas-tool-modal-overlay" onClick={() => setCanvasToolModal(null)}>
|
||||
<div className="studio-canvas-tool-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}>
|
||||
<header className="studio-canvas-tool-modal__header">
|
||||
<h3>{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}</h3>
|
||||
<button type="button" aria-label="关闭" onClick={() => setCanvasToolModal(null)}><CloseOutlined /></button>
|
||||
</header>
|
||||
<div className="studio-canvas-tool-modal__body">
|
||||
{canvasToolModal.tool === "multiGrid" && (
|
||||
<CanvasMultiGridPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
|
||||
)}
|
||||
{canvasToolModal.tool === "upscale" && (
|
||||
<CanvasUpscalePanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
|
||||
)}
|
||||
{canvasToolModal.tool === "inpaint" && (
|
||||
<CanvasInpaintPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { waitForTask } from "../../api/taskSubscription";
|
||||
import { toast } from "../../components/toast/toastStore";
|
||||
import type { CanvasImageNode } from "./canvasTypes";
|
||||
|
||||
interface CanvasToolPanelProps {
|
||||
imageUrl: string;
|
||||
imageNode: CanvasImageNode;
|
||||
onComplete: (resultUrl: string) => void;
|
||||
}
|
||||
|
||||
export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
|
||||
const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!imageUrl) return;
|
||||
setLoading(true);
|
||||
cancelRef.current = false;
|
||||
try {
|
||||
const { taskId } = await aiGenerationClient.createImageTask({
|
||||
model: "gpt-image-2",
|
||||
prompt: prompt || "基于参考图生成多宫格变体",
|
||||
referenceUrls: [imageUrl],
|
||||
gridMode,
|
||||
});
|
||||
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
|
||||
if (resultUrl) {
|
||||
onComplete(resultUrl);
|
||||
toast.success("多宫格生成完成");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [imageUrl, prompt, gridMode, onComplete]);
|
||||
|
||||
return (
|
||||
<div className="studio-canvas-tool-panel">
|
||||
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
|
||||
<div className="studio-canvas-tool-panel__controls">
|
||||
<label className="studio-canvas-tool-panel__label">{"宫格模式"}</label>
|
||||
<div className="studio-canvas-tool-panel__options">
|
||||
{([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => (
|
||||
<button key={value} type="button" className={gridMode === value ? "is-active" : ""} onClick={() => setGridMode(value)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="studio-canvas-tool-panel__label">{"提示词(可选)"}</label>
|
||||
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述多宫格内容变化" />
|
||||
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleGenerate}>
|
||||
{loading ? "生成中..." : "生成多宫格"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasUpscalePanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
|
||||
const [scale, setScale] = useState<"2x" | "4x">("2x");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
const handleUpscale = useCallback(async () => {
|
||||
if (!imageUrl) return;
|
||||
setLoading(true);
|
||||
cancelRef.current = false;
|
||||
try {
|
||||
const { taskId } = await aiGenerationClient.createImageSuperResolveTask({
|
||||
imageUrl,
|
||||
scale,
|
||||
});
|
||||
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
|
||||
if (resultUrl) {
|
||||
onComplete(resultUrl);
|
||||
toast.success("超分完成");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "超分失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [imageUrl, scale, onComplete]);
|
||||
|
||||
return (
|
||||
<div className="studio-canvas-tool-panel">
|
||||
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
|
||||
<div className="studio-canvas-tool-panel__controls">
|
||||
<label className="studio-canvas-tool-panel__label">{"放大倍数"}</label>
|
||||
<div className="studio-canvas-tool-panel__options">
|
||||
{(["2x", "4x"] as const).map((s) => (
|
||||
<button key={s} type="button" className={scale === s ? "is-active" : ""} onClick={() => setScale(s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleUpscale}>
|
||||
{loading ? "处理中..." : "开始超分"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [brushSize, setBrushSize] = useState(30);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDrawingRef = useRef(false);
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
const initCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
|
||||
const getPos = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current!;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
|
||||
};
|
||||
|
||||
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawingRef.current) return;
|
||||
const ctx = canvasRef.current?.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const { x, y } = getPos(e);
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.fillStyle = "rgba(255, 0, 0, 0.4)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, brushSize, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
const getMaskDataUrl = (): string => {
|
||||
const canvas = canvasRef.current!;
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = canvas.width;
|
||||
maskCanvas.height = canvas.height;
|
||||
const srcCtx = canvas.getContext("2d")!;
|
||||
const maskCtx = maskCanvas.getContext("2d")!;
|
||||
const imgData = srcCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const maskData = maskCtx.createImageData(canvas.width, canvas.height);
|
||||
for (let i = 0; i < imgData.data.length; i += 4) {
|
||||
const hasColor = imgData.data[i + 3] > 10;
|
||||
maskData.data[i] = hasColor ? 255 : 0;
|
||||
maskData.data[i + 1] = hasColor ? 255 : 0;
|
||||
maskData.data[i + 2] = hasColor ? 255 : 0;
|
||||
maskData.data[i + 3] = 255;
|
||||
}
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
return maskCanvas.toDataURL("image/png");
|
||||
};
|
||||
|
||||
const handleInpaint = useCallback(async () => {
|
||||
if (!imageUrl || !prompt) {
|
||||
toast.error("请输入重绘提示词");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
cancelRef.current = false;
|
||||
try {
|
||||
const maskDataUrl = getMaskDataUrl();
|
||||
const { taskId } = await aiGenerationClient.createImageEditTask({
|
||||
imageUrl,
|
||||
function: "inpaint",
|
||||
prompt,
|
||||
});
|
||||
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
|
||||
if (resultUrl) {
|
||||
onComplete(resultUrl);
|
||||
toast.success("局部重绘完成");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "局部重绘失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [imageUrl, prompt, onComplete]);
|
||||
return (
|
||||
<div className="studio-canvas-tool-panel studio-canvas-tool-panel--inpaint">
|
||||
<div className="studio-canvas-tool-panel__canvas-wrap">
|
||||
<img src={imageUrl} alt="" className="studio-canvas-tool-panel__canvas-bg" />
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="studio-canvas-tool-panel__canvas"
|
||||
onMouseDown={(e) => { isDrawingRef.current = true; draw(e); }}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={() => { isDrawingRef.current = false; }}
|
||||
onMouseLeave={() => { isDrawingRef.current = false; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="studio-canvas-tool-panel__controls">
|
||||
<label className="studio-canvas-tool-panel__label">{"画笔大小"}</label>
|
||||
<input type="range" min={5} max={80} value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
|
||||
<label className="studio-canvas-tool-panel__label">{"重绘提示词"}</label>
|
||||
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述需要重绘区域的内容" />
|
||||
<div className="studio-canvas-tool-panel__actions">
|
||||
<button type="button" className="studio-canvas-tool-panel__reset" onClick={initCanvas}>{"清除蒙版"}</button>
|
||||
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleInpaint}>
|
||||
{loading ? "处理中..." : "开始重绘"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export function blobToDataUrl(blob: Blob) {
|
||||
|
||||
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
kind: "image",
|
||||
onProgress: (e) => {
|
||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
},
|
||||
@@ -262,7 +262,7 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status:
|
||||
|
||||
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
kind: "video",
|
||||
onProgress: (e) => {
|
||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
},
|
||||
|
||||
@@ -16,10 +16,10 @@ import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import OptimizedImage from "../../components/OptimizedImage";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { cloneWorkflow, createBlankWorkflow } from "../../data/workflows";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import type { WebCanvasWorkflow, WebProjectSummary } from "../../types";
|
||||
import { getCommunityCaseCover, getWorkflowFromCase, shouldShowInCanvasCommunity } from "./communityCaseUtils";
|
||||
import { ossThumb } from "../../utils/ossImageOptimize";
|
||||
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
|
||||
|
||||
interface CommunityPageProps {
|
||||
projects: WebProjectSummary[];
|
||||
@@ -31,23 +31,12 @@ interface CommunityPageProps {
|
||||
onRequireLogin?: (action: string) => boolean | void;
|
||||
}
|
||||
|
||||
const communityCardImages = [
|
||||
`${OSS_MUBAN}/dianshang1.png`,
|
||||
`${OSS_MUBAN}/dianshang2.png`,
|
||||
`${OSS_MUBAN}/dianshang3.png`,
|
||||
`${OSS_MUBAN}/wechat-7.png`,
|
||||
`${OSS_MUBAN}/wechat-8.png`,
|
||||
`${OSS_MUBAN}/wechat-9.png`,
|
||||
];
|
||||
const communityCardImages = ossAssets.community.cardImages;
|
||||
|
||||
const SLIDE_INTERVAL = 3000;
|
||||
const CAROUSEL_VISIBLE_COUNT = 3;
|
||||
const MANUAL_PAUSE_DURATION = 2000;
|
||||
const COMMUNITY_CAROUSEL_VIDEOS = [
|
||||
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test3.mp4",
|
||||
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test4.mp4",
|
||||
"https://stringtest.oss-cn-hangzhou.aliyuncs.com/test6.mp4",
|
||||
];
|
||||
const COMMUNITY_CAROUSEL_VIDEOS = ossAssets.community.carouselVideos;
|
||||
|
||||
function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCanvasWorkflow): WebCanvasWorkflow {
|
||||
const workflow = getWorkflowFromCase(item);
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
|
||||
|
||||
type DialogStyle = "style1" | "style2" | "style3" | "style4";
|
||||
|
||||
interface DialogItem {
|
||||
id: number;
|
||||
style: DialogStyle;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
color: string;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
const dialogStyles: Array<{
|
||||
key: DialogStyle;
|
||||
label: string;
|
||||
description: string;
|
||||
swatchClass: string;
|
||||
}> = [
|
||||
{ key: "style1", label: "白色圆角对话框", description: "适合浅色说明与标注", swatchClass: "is-white" },
|
||||
{ key: "style2", label: "蓝色气泡对话框", description: "适合角色台词与重点提示", swatchClass: "is-blue" },
|
||||
{ key: "style3", label: "黄色提示对话框", description: "适合醒目提醒与强调", swatchClass: "is-amber" },
|
||||
{ key: "style4", label: "灰色简约对话框", description: "适合信息备注与辅助说明", swatchClass: "is-gray" },
|
||||
];
|
||||
|
||||
const textColorOptions = [
|
||||
{ value: "#ffffff", label: "白色" },
|
||||
{ value: "#111827", label: "黑色" },
|
||||
{ value: "#ef4444", label: "红色" },
|
||||
{ value: "#f59e0b", label: "黄色" },
|
||||
{ value: "#165dff", label: "蓝色" },
|
||||
{ value: "#00ff88", label: "绿色" },
|
||||
];
|
||||
|
||||
function DialogGeneratorPage() {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const nextIdRef = useRef(0);
|
||||
const [backgroundUrl, setBackgroundUrl] = useState("");
|
||||
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
|
||||
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
|
||||
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
||||
|
||||
const handleFile = useCallback((file?: File | null) => {
|
||||
if (!file || !file.type.startsWith("image/")) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
setBackgroundUrl(reader.result);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
const addDialog = useCallback((style: DialogStyle) => {
|
||||
nextIdRef.current += 1;
|
||||
const id = nextIdRef.current;
|
||||
setDialogs((current) => [
|
||||
...current,
|
||||
{
|
||||
id,
|
||||
style,
|
||||
x: 30 + (id * 25) % 200,
|
||||
y: 30 + (id * 20) % 150,
|
||||
text: "",
|
||||
color: selectedTextColor,
|
||||
confirmed: false,
|
||||
},
|
||||
]);
|
||||
}, [selectedTextColor]);
|
||||
|
||||
const updateDialog = useCallback((id: number, patch: Partial<DialogItem>) => {
|
||||
setDialogs((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
|
||||
}, []);
|
||||
|
||||
const deleteDialog = useCallback((id: number) => {
|
||||
setDialogs((current) => current.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const startDrag = useCallback((id: number, clientX: number, clientY: number) => {
|
||||
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${id}"]`);
|
||||
if (!dialogEl) return;
|
||||
const rect = dialogEl.getBoundingClientRect();
|
||||
dragRef.current = {
|
||||
id,
|
||||
offsetX: clientX - rect.left,
|
||||
offsetY: clientY - rect.top,
|
||||
};
|
||||
setActiveDragId(id);
|
||||
}, []);
|
||||
|
||||
const moveDrag = useCallback((clientX: number, clientY: number) => {
|
||||
const drag = dragRef.current;
|
||||
const preview = previewRef.current;
|
||||
if (!drag || !preview) return;
|
||||
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${drag.id}"]`);
|
||||
if (!dialogEl) return;
|
||||
|
||||
const bounds = preview.getBoundingClientRect();
|
||||
const nextX = Math.max(0, Math.min(clientX - drag.offsetX - bounds.left, bounds.width - dialogEl.offsetWidth));
|
||||
const nextY = Math.max(0, Math.min(clientY - drag.offsetY - bounds.top, bounds.height - dialogEl.offsetHeight));
|
||||
updateDialog(drag.id, { x: nextX, y: nextY });
|
||||
}, [updateDialog]);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
setActiveDragId(null);
|
||||
}, []);
|
||||
|
||||
const handleCanvasMouseMove = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
moveDrag(event.clientX, event.clientY);
|
||||
}, [moveDrag]);
|
||||
|
||||
const handleCanvasTouchMove = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
const touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
moveDrag(touch.clientX, touch.clientY);
|
||||
}, [moveDrag]);
|
||||
|
||||
return (
|
||||
<section className="dialog-generator-page page-motion">
|
||||
<div className="dialog-generator-shell">
|
||||
<aside className="dialog-generator-panel">
|
||||
<div className="dialog-generator-heading">
|
||||
<span className="dialog-generator-kicker">Interactive Dialog</span>
|
||||
<h1>交互式对话框生成器</h1>
|
||||
<p>上传背景图,在画面上添加可拖拽、可编辑的文字图层,用于图片标注、剧情分镜和互动内容设计。</p>
|
||||
</div>
|
||||
|
||||
<div className="dialog-generator-section">
|
||||
<h2>上传背景图片</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-generator-drop"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
handleFile(event.dataTransfer.files[0]);
|
||||
}}
|
||||
>
|
||||
<span className="dialog-generator-drop-icon">🖼</span>
|
||||
<strong>点击或拖拽图片到此处</strong>
|
||||
<small>支持 JPG、PNG、WEBP 格式</small>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(event) => handleFile(event.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dialog-generator-section">
|
||||
<h2>点击添加文字</h2>
|
||||
<p className="dialog-generator-hint">每点一次即在预览区新增一个可编辑文字图层。</p>
|
||||
<div className="dialog-generator-color-picker" role="radiogroup" aria-label="文字颜色">
|
||||
{textColorOptions.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`dialog-generator-color${selectedTextColor === item.value ? " is-active" : ""}`}
|
||||
style={{ "--text-color": item.value } as CSSProperties}
|
||||
aria-checked={selectedTextColor === item.value}
|
||||
role="radio"
|
||||
onClick={() => setSelectedTextColor(item.value)}
|
||||
>
|
||||
<span />
|
||||
<strong>{item.label}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dialog-generator-style-list">
|
||||
{dialogStyles.map((item) => (
|
||||
<button key={item.key} type="button" className="dialog-generator-style" onClick={() => addDialog(item.key)}>
|
||||
<span className={`dialog-generator-swatch ${item.swatchClass}`} />
|
||||
<span>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="dialog-generator-clear" onClick={() => setDialogs([])}>
|
||||
清空全部文字
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="dialog-generator-preview-card">
|
||||
<div className="dialog-generator-preview-head">
|
||||
<div>
|
||||
<span>Preview</span>
|
||||
<h2>预览区域</h2>
|
||||
</div>
|
||||
<p>拖动文字定位,输入文字后点击确认,确认后只保留文字图层,双击可重新编辑。</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="dialog-generator-preview"
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
onTouchMove={handleCanvasTouchMove}
|
||||
onTouchEnd={endDrag}
|
||||
>
|
||||
{backgroundUrl ? <div className="dialog-generator-image" style={{ backgroundImage: `url(${backgroundUrl})` }} /> : null}
|
||||
{!backgroundUrl ? (
|
||||
<div className="dialog-generator-empty">
|
||||
<span>🖼</span>
|
||||
<p>上传图片后开始编辑</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{dialogs.map((dialog) => (
|
||||
<div
|
||||
key={dialog.id}
|
||||
data-dialog-id={dialog.id}
|
||||
className={`dialog-generator-bubble ${dialog.style}${dialog.confirmed ? " is-confirmed" : ""}${activeDragId === dialog.id ? " is-dragging" : ""}`}
|
||||
style={{ left: dialog.x, top: dialog.y, "--dialog-text-color": dialog.color } as CSSProperties}
|
||||
onMouseDown={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("textarea,button")) return;
|
||||
startDrag(dialog.id, event.clientX, event.clientY);
|
||||
event.preventDefault();
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("textarea,button")) return;
|
||||
const touch = event.touches[0];
|
||||
if (touch) startDrag(dialog.id, touch.clientX, touch.clientY);
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (dialog.confirmed) updateDialog(dialog.id, { confirmed: false });
|
||||
}}
|
||||
>
|
||||
{!dialog.confirmed ? (
|
||||
<button type="button" className="dialog-generator-delete" onClick={() => deleteDialog(dialog.id)} aria-label="删除文字">
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
{dialog.confirmed ? (
|
||||
<div className="dialog-generator-text-display">{dialog.text}</div>
|
||||
) : (
|
||||
<textarea
|
||||
className="dialog-generator-text"
|
||||
rows={2}
|
||||
placeholder="输入文本..."
|
||||
value={dialog.text}
|
||||
onChange={(event) => updateDialog(dialog.id, { text: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
{!dialog.confirmed ? (
|
||||
<div className="dialog-generator-bubble-bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-generator-confirm"
|
||||
onClick={() => {
|
||||
if (dialog.text.trim()) {
|
||||
updateDialog(dialog.id, { text: dialog.text.trim(), confirmed: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
✓ 确认
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default DialogGeneratorPage;
|
||||
@@ -114,12 +114,12 @@ function DigitalHumanPage({
|
||||
keepaliveRestoredRef.current = true;
|
||||
const saved = loadToolTaskState("digital-human");
|
||||
if (!saved || saved.resultUrl) return;
|
||||
setIsProcessing(true);
|
||||
setIsCreating(true);
|
||||
cancelRef.current = false;
|
||||
pollRunRef.current += 1;
|
||||
setActiveTaskId(saved.taskId);
|
||||
void waitForTaskResult(saved.taskId).catch(() => {});
|
||||
setStatus("正在恢复数字人任务...");
|
||||
setNotice("正在恢复数字人任务...");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FolderAddOutlined,
|
||||
HistoryOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks } from "./ecommerceVideoService";
|
||||
import { runVideoPlan, renderSceneImage, renderScene, buildSceneTasks, saveVideoHistory } from "./ecommerceVideoService";
|
||||
import {
|
||||
PLAN_STEP_LABELS,
|
||||
PLAN_STEPS_DISPLAY,
|
||||
@@ -33,12 +34,15 @@ import {
|
||||
interface EcommerceVideoWorkspaceProps {
|
||||
isAuthenticated: boolean;
|
||||
productImageDataUrls: string[];
|
||||
productImageFiles?: Array<File | undefined>;
|
||||
requirement: string;
|
||||
platform: string;
|
||||
aspectRatio: string;
|
||||
durationSeconds: number;
|
||||
resolution: string;
|
||||
onRequestLogin?: () => void;
|
||||
onOpenHistory?: () => void;
|
||||
triggerPlan?: number;
|
||||
}
|
||||
|
||||
const ALL_STEPS: PlanStep[] = [
|
||||
@@ -94,12 +98,15 @@ function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress
|
||||
export default function EcommerceVideoWorkspace({
|
||||
isAuthenticated,
|
||||
productImageDataUrls,
|
||||
productImageFiles = [],
|
||||
requirement,
|
||||
platform,
|
||||
aspectRatio,
|
||||
durationSeconds,
|
||||
resolution,
|
||||
onRequestLogin,
|
||||
onOpenHistory,
|
||||
triggerPlan,
|
||||
}: EcommerceVideoWorkspaceProps) {
|
||||
const [stage, setStage] = useState<EcommerceVideoStage>("idle");
|
||||
const [planResult, setPlanResult] = useState<EcommerceVideoPlanResult | null>(null);
|
||||
@@ -111,6 +118,7 @@ export default function EcommerceVideoWorkspace({
|
||||
const [failedStep, setFailedStep] = useState<PlanStep | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const renderAbortRef = useRef({ current: false });
|
||||
const setView = useAppStore((s) => s.setView);
|
||||
@@ -145,26 +153,45 @@ export default function EcommerceVideoWorkspace({
|
||||
saveEcommerceVideoState({ inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls });
|
||||
}, [inputFingerprint, stage, completedSteps, planResult, planProgress, scenes, sourceImageUrls]);
|
||||
|
||||
// ── Auto-advance: skip manual "next step" clicks ─────────
|
||||
const autoAdvanceTriggeredRef = useRef(false);
|
||||
// ── Auto-advance: automatically run the full pipeline ─────────
|
||||
useEffect(() => {
|
||||
if (autoAdvanceTriggeredRef.current) return;
|
||||
const delay = 600;
|
||||
if (stage === "planned" && planResult && scenes.length > 0) {
|
||||
autoAdvanceTriggeredRef.current = true;
|
||||
const timer = setTimeout(() => { void handleGenerateImages(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "imaged" && scenes.every((s) => s.imageUrl)) {
|
||||
autoAdvanceTriggeredRef.current = true;
|
||||
const timer = setTimeout(() => { void handleRenderVideos(); }, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (stage === "idle" || stage === "cancelled") {
|
||||
autoAdvanceTriggeredRef.current = false;
|
||||
}
|
||||
}, [stage, scenes, planResult]);
|
||||
|
||||
// ── External trigger: start plan from parent ────────────────
|
||||
const triggerPlanPrevRef = useRef(triggerPlan);
|
||||
useEffect(() => {
|
||||
if (triggerPlan != null && triggerPlan !== triggerPlanPrevRef.current) {
|
||||
triggerPlanPrevRef.current = triggerPlan;
|
||||
void handlePlan();
|
||||
}
|
||||
}, [triggerPlan]);
|
||||
|
||||
// ── Auto-save: persist completed results to server ──────────
|
||||
const historySavedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (stage !== "completed") { historySavedRef.current = false; return; }
|
||||
if (historySavedRef.current) return;
|
||||
if (!planResult || !scenes.length) return;
|
||||
historySavedRef.current = true;
|
||||
const title = planResult.storyboard?.video_title || planResult.summary?.product_name || "电商广告视频";
|
||||
saveVideoHistory({
|
||||
title,
|
||||
config: { platform, aspectRatio, durationSeconds, resolution },
|
||||
plan: planResult as unknown as Record<string, unknown>,
|
||||
scenes: scenes.map((s) => ({ sceneId: s.sceneId, prompt: s.prompt, imageUrl: s.imageUrl, videoUrl: s.resultUrl })),
|
||||
sourceImageUrls,
|
||||
}).catch(() => {});
|
||||
}, [stage, planResult, scenes, sourceImageUrls, platform, aspectRatio, durationSeconds, resolution]);
|
||||
|
||||
// ── Keep-alive: resume polling for running tasks ──────────
|
||||
useEffect(() => {
|
||||
if (keepalivePollingStartedRef.current) return;
|
||||
@@ -351,8 +378,9 @@ export default function EcommerceVideoWorkspace({
|
||||
});
|
||||
};
|
||||
try {
|
||||
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
|
||||
const result = await runVideoPlan(
|
||||
productImageDataUrls, requirement, buildConfig(),
|
||||
productImageSources, requirement, buildConfig(),
|
||||
{
|
||||
onStepStart: (step) => setCurrentStep(step),
|
||||
onStepDone: (step) => {
|
||||
@@ -431,7 +459,7 @@ export default function EcommerceVideoWorkspace({
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderSceneImage(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio },
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, aspectRatio: ratio, productImageUrls: sourceImageUrls },
|
||||
{
|
||||
onSceneImageSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, imageTaskId: taskId, status: "running" } : s));
|
||||
@@ -486,7 +514,7 @@ export default function EcommerceVideoWorkspace({
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, aspectRatio, resolution: quality },
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl, productImageUrls: sourceImageUrls, aspectRatio, resolution: quality },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => {
|
||||
persistScenes(currentScenes.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s));
|
||||
@@ -529,7 +557,7 @@ export default function EcommerceVideoWorkspace({
|
||||
setScenes((prev) => prev.map((s) => s.sceneId === scene.sceneId ? { ...s, status: "pending", error: undefined } : s));
|
||||
try {
|
||||
await renderScene(
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, aspectRatio, resolution: mapResolutionToQuality(resolution) },
|
||||
{ sceneId: scene.sceneId, prompt: scene.prompt, durationSeconds: scene.durationSeconds, imageUrl: scene.imageUrl!, productImageUrls: sourceImageUrls, aspectRatio, resolution: mapResolutionToQuality(resolution) },
|
||||
{
|
||||
onSceneSubmitted: (id, taskId) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, taskId, status: "running" } : s)),
|
||||
onSceneProgress: (id, progress) => setScenes((prev) => prev.map((s) => s.sceneId === id ? { ...s, progress } : s)),
|
||||
@@ -573,6 +601,11 @@ export default function EcommerceVideoWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-flowbar__actions">
|
||||
{onOpenHistory ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost" onClick={onOpenHistory} title="生成记录">
|
||||
<HistoryOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{error ? <span className="ecom-video-flowbar__error" role="alert">{error}</span> : null}
|
||||
{stage === "idle" && planProgress && (planProgress.summary || planProgress.creatives || planProgress.storyboard) ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
@@ -580,12 +613,6 @@ export default function EcommerceVideoWorkspace({
|
||||
<ReloadOutlined /> 继续
|
||||
</button>
|
||||
) : null}
|
||||
{stage !== "planning" && stage !== "imaging" && stage !== "rendering" ? (
|
||||
<button type="button" className="ecom-video-flow-action"
|
||||
onClick={() => void handlePlan()} title={planProgress ? "从头重新策划" : "一键策划"}>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
{stage === "planned" || stage === "imaged" ? (
|
||||
<button type="button" className="ecom-video-flow-action ecom-video-flow-action--ghost"
|
||||
onClick={() => void handleGenerateImages()} title={stage === "imaged" ? "重新生成分镜图" : "生成图片"}>
|
||||
@@ -638,11 +665,7 @@ export default function EcommerceVideoWorkspace({
|
||||
{scenes.length > 0 ? scenes.map((s) => (
|
||||
<div key={`trunk-${s.sceneId}`} className="ecom-video-tree__branch-tap" />
|
||||
)) : (
|
||||
<>
|
||||
<div className="ecom-video-tree__branch-tap" />
|
||||
<div className="ecom-video-tree__branch-tap" />
|
||||
<div className="ecom-video-tree__branch-tap" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -672,7 +695,7 @@ export default function EcommerceVideoWorkspace({
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`}>
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--image${imgReady ? " is-completed" : imgRunning ? " is-active" : ""}`} onClick={imgReady ? () => setPreviewMedia({ url: scene.imageUrl!, type: "image" }) : undefined} style={imgReady ? { cursor: "pointer" } : undefined}>
|
||||
{imgReady ? (
|
||||
<img src={scene.imageUrl!} alt={`分镜${scene.sceneId}`} />
|
||||
) : (
|
||||
@@ -688,7 +711,7 @@ export default function EcommerceVideoWorkspace({
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</div>
|
||||
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`}>
|
||||
<article className={`ecom-video-tree-node ecom-video-tree-node--video${vidReady ? " is-completed" : vidRunning ? " is-active" : vidFailed ? " is-failed" : ""}`} onClick={vidReady ? () => setPreviewMedia({ url: scene.resultUrl!, type: "video" }) : undefined} style={vidReady ? { cursor: "pointer" } : undefined}>
|
||||
{vidReady ? (
|
||||
<video src={scene.resultUrl!} muted playsInline loop autoPlay />
|
||||
) : (
|
||||
@@ -709,12 +732,11 @@ export default function EcommerceVideoWorkspace({
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
[1, 2, 3].map((n) => (
|
||||
<div key={n} className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`} style={{ animationDelay: `${n * 120}ms` }}>
|
||||
<div className={`ecom-video-tree__row ecom-video-tree__row--empty${stage === "planning" ? " is-planning" : ""}`}>
|
||||
<article className="ecom-video-tree-node ecom-video-tree-node--text">
|
||||
<div className="ecom-video-tree-node__inner">
|
||||
<span className="ecom-video-tree-node__title">分镜文本{n}</span>
|
||||
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "等待策划"}</span>
|
||||
<span className="ecom-video-tree-node__title">分镜策划</span>
|
||||
<span className="ecom-video-tree-node__desc">{stage === "planning" ? "策划中..." : "点击一键策划开始"}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
@@ -724,7 +746,7 @@ export default function EcommerceVideoWorkspace({
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜图{n}</span>
|
||||
<span className="ecom-video-tree-node__tag">分镜图</span>
|
||||
</article>
|
||||
<div className="ecom-video-tree__arrow" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 20" fill="none"><path d="M0 10 H28 M22 4 L30 10 L22 16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
@@ -733,10 +755,9 @@ export default function EcommerceVideoWorkspace({
|
||||
<div className="ecom-video-tree-node__placeholder">
|
||||
{stage === "planning" ? <LoadingOutlined /> : <span>待生成</span>}
|
||||
</div>
|
||||
<span className="ecom-video-tree-node__tag">分镜视频{n}</span>
|
||||
<span className="ecom-video-tree-node__tag">分镜视频</span>
|
||||
</article>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -753,6 +774,19 @@ export default function EcommerceVideoWorkspace({
|
||||
) : null}
|
||||
{actionNotice ? <div className="ecom-video-flow-notice">{actionNotice}</div> : null}
|
||||
</section>
|
||||
|
||||
{previewMedia ? (
|
||||
<div className="ecom-video-preview-overlay" onClick={() => setPreviewMedia(null)}>
|
||||
<button type="button" className="ecom-video-preview-overlay__close" onClick={() => setPreviewMedia(null)}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
{previewMedia.type === "image" ? (
|
||||
<img src={previewMedia.url} alt="预览" onClick={(e) => e.stopPropagation()} />
|
||||
) : (
|
||||
<video src={previewMedia.url} controls autoPlay onClick={(e) => e.stopPropagation()} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import ecommerceCarouselGenerated from "../../assets/ecommerce-carousel-generated.png";
|
||||
import moreTemplateSlide1 from "../../assets/more-template-carousel/slide-1.jpg";
|
||||
import moreTemplateSlide2 from "../../assets/more-template-carousel/slide-2.jpg";
|
||||
import moreTemplateSlide3 from "../../assets/more-template-carousel/slide-3.jpg";
|
||||
import moreTemplateSlide4 from "../../assets/more-template-carousel/slide-4.png";
|
||||
import moreTemplateSlide5 from "../../assets/more-template-carousel/slide-5.gif";
|
||||
import ecommerceHeroSlide1 from "../../assets/ecommerce-hero-carousel/slide-1.webp";
|
||||
import ecommerceHeroSlide2 from "../../assets/ecommerce-hero-carousel/slide-2.webp";
|
||||
import ecommerceHeroSlide3 from "../../assets/ecommerce-hero-carousel/slide-3.webp";
|
||||
import ecommerceHeroSlide4 from "../../assets/ecommerce-hero-carousel/slide-4.webp";
|
||||
import ecommerceHeroSlide5 from "../../assets/ecommerce-hero-carousel/slide-5.webp";
|
||||
import ecommerceCarouselImage1 from "../../../tu/微信图片_20260514125332_8_2.png";
|
||||
import ecommerceCarouselImage2 from "../../../tu/微信图片_20260514125332_9_2.png";
|
||||
import ecommerceCarouselImage3 from "../../../tu/微信图片_20260514125332_7_2.png";
|
||||
import ecommerceCarouselImage4 from "../../../tu/微信图片_20260514125332_12_2.png";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
|
||||
const [
|
||||
moreTemplateSlide1,
|
||||
moreTemplateSlide2,
|
||||
moreTemplateSlide3,
|
||||
moreTemplateSlide4,
|
||||
moreTemplateSlide5,
|
||||
] = ossAssets.ecommerce.templateSlides;
|
||||
const [
|
||||
ecommerceHeroSlide1,
|
||||
ecommerceHeroSlide2,
|
||||
ecommerceHeroSlide3,
|
||||
ecommerceHeroSlide4,
|
||||
ecommerceHeroSlide5,
|
||||
] = ossAssets.ecommerce.heroSlides;
|
||||
const [
|
||||
ecommerceCarouselImage1,
|
||||
ecommerceCarouselImage2,
|
||||
ecommerceCarouselImage3,
|
||||
ecommerceCarouselImage4,
|
||||
ecommerceCarouselImage5,
|
||||
ecommerceCarouselImage6,
|
||||
] = ossAssets.ecommerce.templateCases;
|
||||
const ecommerceCarouselGenerated = ossAssets.ecommerce.generated;
|
||||
|
||||
export interface TemplateCase {
|
||||
title: string;
|
||||
@@ -124,6 +134,6 @@ export const templateCases: TemplateCase[] = [
|
||||
title: "促销卖点组合图",
|
||||
category: "详情图",
|
||||
summary: "把成分、规格、卖点拆成清晰的详情页模块。",
|
||||
imageUrl: "https://picsum.photos/id/1080/900/620",
|
||||
imageUrl: ecommerceCarouselImage6,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -19,6 +19,102 @@ import type {
|
||||
PlanStep,
|
||||
} from "./ecommerceVideoTypes";
|
||||
|
||||
type UploadAssetByUrl = typeof aiGenerationClient.uploadAssetByUrl;
|
||||
|
||||
interface DurableMediaUrl {
|
||||
url: string | null;
|
||||
originalUrl?: string | null;
|
||||
ossKey?: string | null;
|
||||
}
|
||||
|
||||
const TEMP_MEDIA_HOST_RE = /^file\d*\.aitohumanize\.com$/i;
|
||||
const OSS_MEDIA_HOST_RE = /\.oss-[^.]+\.aliyuncs\.com$/i;
|
||||
|
||||
function isTemporaryProviderUrl(url: string): boolean {
|
||||
try {
|
||||
return TEMP_MEDIA_HOST_RE.test(new URL(url).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDurableOssUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "https:" && OSS_MEDIA_HOST_RE.test(parsed.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getMediaExtension(url: string, mimeType: string): string {
|
||||
const normalizedMime = mimeType.split(";")[0]?.trim().toLowerCase();
|
||||
if (normalizedMime === "image/jpeg") return "jpg";
|
||||
if (normalizedMime === "image/png") return "png";
|
||||
if (normalizedMime === "image/webp") return "webp";
|
||||
if (normalizedMime === "image/gif") return "gif";
|
||||
if (normalizedMime === "video/mp4") return "mp4";
|
||||
if (normalizedMime === "video/webm") return "webm";
|
||||
if (normalizedMime === "video/quicktime") return "mov";
|
||||
|
||||
try {
|
||||
const matched = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
|
||||
if (matched?.[1]) return matched[1].toLowerCase();
|
||||
} catch {
|
||||
// Keep mime fallback below.
|
||||
}
|
||||
|
||||
return mimeType.startsWith("video/") ? "mp4" : "png";
|
||||
}
|
||||
|
||||
function buildDurableMediaName(prefix: string, url: string, mimeType: string): string {
|
||||
const normalized = prefix
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 80)
|
||||
.trim();
|
||||
return `${normalized || "ecommerce-video-media"}.${getMediaExtension(url, mimeType)}`;
|
||||
}
|
||||
|
||||
export async function resolveDurableMediaUrl(
|
||||
url: string | null | undefined,
|
||||
options: {
|
||||
mediaType: "image" | "video";
|
||||
namePrefix: string;
|
||||
scope?: string;
|
||||
uploadAssetByUrl?: UploadAssetByUrl;
|
||||
},
|
||||
): Promise<DurableMediaUrl> {
|
||||
const sourceUrl = String(url || "").trim();
|
||||
if (!sourceUrl) return { url: null };
|
||||
if (isDurableOssUrl(sourceUrl)) return { url: sourceUrl };
|
||||
|
||||
const mimeType = options.mediaType === "video" ? "video/mp4" : "image/png";
|
||||
const uploadAssetByUrl = options.uploadAssetByUrl || aiGenerationClient.uploadAssetByUrl.bind(aiGenerationClient);
|
||||
|
||||
try {
|
||||
const uploaded = await uploadAssetByUrl({
|
||||
sourceUrl,
|
||||
name: buildDurableMediaName(options.namePrefix, sourceUrl, mimeType),
|
||||
mimeType,
|
||||
scope: options.scope || "ecommerce-video-history",
|
||||
});
|
||||
return {
|
||||
url: uploaded.url || null,
|
||||
originalUrl: sourceUrl,
|
||||
ossKey: uploaded.ossKey || null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error || "");
|
||||
console.warn("[ecommerce-video] history media persistence failed:", message);
|
||||
if (isTemporaryProviderUrl(sourceUrl)) {
|
||||
return { url: null, originalUrl: sourceUrl };
|
||||
}
|
||||
return { url: sourceUrl };
|
||||
}
|
||||
}
|
||||
|
||||
export interface PlanCallbacks {
|
||||
onStepStart: (step: PlanStep) => void;
|
||||
onStepDone: (step: PlanStep) => void;
|
||||
@@ -30,13 +126,61 @@ export interface PlanCallbacks {
|
||||
resumeFrom?: EcommerceVideoPlanProgress;
|
||||
}
|
||||
|
||||
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
|
||||
|
||||
function readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("File read failed"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRemoteImageUrl(source: string): string | null {
|
||||
try {
|
||||
const url = new URL(source, typeof window !== "undefined" ? window.location.href : undefined);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadProductImageSource(source: string | Blob): Promise<string> {
|
||||
if (typeof source === "string") {
|
||||
if (source.startsWith("blob:")) {
|
||||
throw new Error(LOCAL_PREVIEW_MISSING_FILE_MESSAGE);
|
||||
}
|
||||
|
||||
if (source.startsWith("data:")) {
|
||||
const mimeType = normalizeEcommerceImageMime(source.match(/^data:([^;,]+)/)?.[1] || "image/png");
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl: source, mimeType, scope: "ecommerce-product" });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
const remoteUrl = normalizeRemoteImageUrl(source);
|
||||
if (remoteUrl) {
|
||||
const result = await aiGenerationClient.uploadAssetByUrl({ sourceUrl: remoteUrl, scope: "ecommerce-product" });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
throw new Error("Unsupported product image URL. Please re-upload the product image.");
|
||||
}
|
||||
|
||||
const mimeType = normalizeEcommerceImageMime(source.type || "image/png");
|
||||
const blob = source.type === mimeType ? source : new Blob([source], { type: mimeType });
|
||||
const dataUrl = await readBlobAsDataUrl(blob);
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full ad video planning pipeline.
|
||||
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
|
||||
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
|
||||
*/
|
||||
export async function runVideoPlan(
|
||||
imageDataUrls: string[],
|
||||
imageSources: Array<string | Blob>,
|
||||
manualText: string,
|
||||
config: AdVideoUserConfig,
|
||||
callbacks: PlanCallbacks,
|
||||
@@ -45,41 +189,30 @@ export async function runVideoPlan(
|
||||
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
|
||||
const emit = () => callbacks.onPartialProgress?.({ ...progress });
|
||||
|
||||
// ── Step: upload ──────────────────────────────────────
|
||||
// Step: upload
|
||||
if (!progress.imageUrls?.length) {
|
||||
onStepStart("upload");
|
||||
const imageUrls: string[] = [];
|
||||
const rejected: string[] = [];
|
||||
for (const srcUrl of imageDataUrls) {
|
||||
for (const source of imageSources) {
|
||||
try {
|
||||
const resp = await fetch(srcUrl);
|
||||
const rawBlob = await resp.blob();
|
||||
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
|
||||
imageUrls.push(result.url);
|
||||
imageUrls.push(await uploadProductImageSource(source));
|
||||
} catch (err) {
|
||||
rejected.push(err instanceof Error ? err.message : "图片上传失败");
|
||||
rejected.push(err instanceof Error ? err.message : "Image upload failed");
|
||||
}
|
||||
}
|
||||
if (rejected.length) {
|
||||
progress.uploadWarnings = rejected;
|
||||
callbacks.onUploadRejected?.(rejected);
|
||||
}
|
||||
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
|
||||
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
|
||||
progress.imageUrls = imageUrls;
|
||||
onStepDone("upload");
|
||||
callbacks.onImagesUploaded?.(imageUrls);
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: analyze ─────────────────────────────────────
|
||||
// Step: analyze
|
||||
if (progress.imageDescription === undefined) {
|
||||
onStepStart("analyze");
|
||||
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
|
||||
@@ -87,7 +220,7 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: summary ─────────────────────────────────────
|
||||
// Step: summary
|
||||
if (!progress.summary) {
|
||||
onStepStart("summary");
|
||||
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
|
||||
@@ -95,7 +228,7 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: selling ─────────────────────────────────────
|
||||
// Step: selling
|
||||
if (!progress.selling) {
|
||||
onStepStart("selling");
|
||||
progress.selling = await extractSellingPoints(progress.summary, signal);
|
||||
@@ -103,16 +236,16 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: creative ────────────────────────────────────
|
||||
// Step: creative
|
||||
if (!progress.creatives?.length) {
|
||||
onStepStart("creative");
|
||||
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
|
||||
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
|
||||
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
|
||||
onStepDone("creative");
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: storyboard ──────────────────────────────────
|
||||
// Step: storyboard
|
||||
if (!progress.storyboard) {
|
||||
onStepStart("storyboard");
|
||||
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
|
||||
@@ -120,7 +253,7 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: prompts ─────────────────────────────────────
|
||||
// Step: prompts
|
||||
if (!progress.videoPrompts) {
|
||||
onStepStart("prompts");
|
||||
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
|
||||
@@ -128,7 +261,7 @@ export async function runVideoPlan(
|
||||
emit();
|
||||
}
|
||||
|
||||
// ── Step: compliance ──────────────────────────────────
|
||||
// Step: compliance
|
||||
if (!progress.compliance) {
|
||||
onStepStart("compliance");
|
||||
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
|
||||
@@ -152,6 +285,7 @@ export interface RenderSceneImageInput {
|
||||
sceneId: number;
|
||||
prompt: string;
|
||||
aspectRatio: string;
|
||||
productImageUrls: string[];
|
||||
}
|
||||
|
||||
export interface RenderImageCallbacks {
|
||||
@@ -171,19 +305,22 @@ export async function renderSceneImage(
|
||||
prompt: input.prompt,
|
||||
ratio: input.aspectRatio,
|
||||
quality: "2K",
|
||||
referenceUrls: input.productImageUrls,
|
||||
});
|
||||
|
||||
callbacks.onSceneImageSubmitted(input.sceneId, taskId);
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef,
|
||||
kind: "image",
|
||||
model: "gpt-image-2",
|
||||
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
|
||||
});
|
||||
|
||||
if (resultUrl) {
|
||||
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
|
||||
} else {
|
||||
callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
|
||||
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +329,7 @@ export interface RenderSceneInput {
|
||||
prompt: string;
|
||||
durationSeconds: number;
|
||||
imageUrl: string;
|
||||
productImageUrls: string[];
|
||||
aspectRatio: string;
|
||||
resolution: string;
|
||||
model?: string;
|
||||
@@ -209,9 +347,10 @@ export async function renderScene(
|
||||
callbacks: RenderCallbacks,
|
||||
abortRef: { current: boolean },
|
||||
): Promise<void> {
|
||||
const allReferenceUrls = [...input.productImageUrls, input.imageUrl];
|
||||
const model = resolveVideoRequestModel({
|
||||
model: input.model || "happyhorse-1.0",
|
||||
referenceUrls: [input.imageUrl],
|
||||
referenceUrls: allReferenceUrls,
|
||||
});
|
||||
|
||||
const { taskId } = await aiGenerationClient.createVideoTask({
|
||||
@@ -222,7 +361,7 @@ export async function renderScene(
|
||||
quality: input.resolution,
|
||||
resolution: input.resolution,
|
||||
frameMode: "start-end",
|
||||
referenceUrls: [input.imageUrl],
|
||||
referenceUrls: allReferenceUrls,
|
||||
hasReferenceVideo: false,
|
||||
});
|
||||
|
||||
@@ -230,13 +369,15 @@ export async function renderScene(
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef,
|
||||
kind: "video",
|
||||
model,
|
||||
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
|
||||
});
|
||||
|
||||
if (resultUrl) {
|
||||
callbacks.onSceneCompleted(input.sceneId, resultUrl);
|
||||
} else {
|
||||
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
|
||||
callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,3 +395,138 @@ export function buildSceneTasks(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Video History API
|
||||
|
||||
export interface VideoHistoryScene {
|
||||
sceneId: number;
|
||||
prompt: string;
|
||||
imageUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
}
|
||||
|
||||
interface SaveVideoHistoryPayload {
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
plan: Record<string, unknown>;
|
||||
scenes: VideoHistoryScene[];
|
||||
sourceImageUrls: string[];
|
||||
uploadAssetByUrl?: UploadAssetByUrl;
|
||||
}
|
||||
|
||||
export interface VideoHistoryItem {
|
||||
id: number;
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
scenes: VideoHistoryScene[];
|
||||
sourceImageUrls: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VideoHistoryListResponse {
|
||||
items: VideoHistoryItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
import { getStoredToken } from "../../api/serverConnection";
|
||||
|
||||
const API_BASE = "/api/ai/ecommerce/video-history";
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = getStoredToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
|
||||
const uploadAssetByUrl = payload.uploadAssetByUrl;
|
||||
const scenes = await Promise.all(
|
||||
payload.scenes.map(async (scene) => {
|
||||
const [image, video] = await Promise.all([
|
||||
resolveDurableMediaUrl(scene.imageUrl, {
|
||||
mediaType: "image",
|
||||
namePrefix: `ecommerce-scene-${scene.sceneId}-image`,
|
||||
uploadAssetByUrl,
|
||||
}),
|
||||
resolveDurableMediaUrl(scene.videoUrl, {
|
||||
mediaType: "video",
|
||||
namePrefix: `ecommerce-scene-${scene.sceneId}-video`,
|
||||
uploadAssetByUrl,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
...scene,
|
||||
imageUrl: image.url,
|
||||
videoUrl: video.url,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const sourceImageUrls = (
|
||||
await Promise.all(
|
||||
payload.sourceImageUrls.map((url, index) =>
|
||||
resolveDurableMediaUrl(url, {
|
||||
mediaType: "image",
|
||||
namePrefix: `ecommerce-source-${index + 1}`,
|
||||
uploadAssetByUrl,
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
.map((item) => item.url)
|
||||
.filter((url): url is string => Boolean(url));
|
||||
|
||||
return {
|
||||
...payload,
|
||||
scenes,
|
||||
sourceImageUrls,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
|
||||
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
|
||||
const res = await fetch(API_BASE, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
||||
body: JSON.stringify(historyPayload),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save video history");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
|
||||
return {
|
||||
...item,
|
||||
scenes: item.scenes.map((scene) => ({
|
||||
...scene,
|
||||
imageUrl: scene.imageUrl && !isTemporaryProviderUrl(scene.imageUrl) ? scene.imageUrl : null,
|
||||
videoUrl: scene.videoUrl && !isTemporaryProviderUrl(scene.videoUrl) ? scene.videoUrl : null,
|
||||
})),
|
||||
sourceImageUrls: item.sourceImageUrls.filter((url) => !isTemporaryProviderUrl(url)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchVideoHistory(
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
): Promise<VideoHistoryListResponse> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}?limit=${limit}&offset=${offset}`,
|
||||
{ headers: getAuthHeaders() },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch video history");
|
||||
const history = (await res.json()) as VideoHistoryListResponse;
|
||||
return {
|
||||
...history,
|
||||
items: history.items.map(removeTemporaryHistoryUrls),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteVideoHistory(id: number): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete video history");
|
||||
}
|
||||
|
||||
@@ -7,15 +7,18 @@ import {
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
|
||||
import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
type CloneOutputKey = string;
|
||||
type CloneSetCountKey = string;
|
||||
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
|
||||
type CloneOutputKey = ProductSetOutputKey | "hot" | "video-outfit";
|
||||
type CloneSetCountKey = "selling" | "white" | "scene";
|
||||
type CloneModelPanelTab = "scene" | "model";
|
||||
type CloneReferenceMode = "upload" | "link";
|
||||
type CloneReplicateLevelKey = string;
|
||||
type CloneVideoQualityKey = string;
|
||||
type CloneReplicateLevelKey = "style" | "high";
|
||||
type CloneVideoQualityKey = "standard" | "high" | "ultra";
|
||||
type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio";
|
||||
type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body";
|
||||
|
||||
interface CloneImageItem {
|
||||
id: string;
|
||||
@@ -24,7 +27,7 @@ interface CloneImageItem {
|
||||
}
|
||||
|
||||
interface CloneBasicSelectItem {
|
||||
key: string;
|
||||
key: CloneBasicSelectKey;
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
@@ -32,7 +35,7 @@ interface CloneBasicSelectItem {
|
||||
}
|
||||
|
||||
interface CloneModelSelectItem {
|
||||
key: string;
|
||||
key: CloneModelSelectKey;
|
||||
label: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
@@ -76,7 +79,7 @@ interface EcommerceClonePanelProps {
|
||||
cloneOutput: CloneOutputKey;
|
||||
cloneOutputOptions: CloneOutputOption[];
|
||||
cloneBasicSelects: CloneBasicSelectItem[];
|
||||
openCloneBasicSelect: string | null;
|
||||
openCloneBasicSelect: CloneBasicSelectKey | null;
|
||||
cloneReferenceMode: CloneReferenceMode;
|
||||
cloneReferenceImages: CloneImageItem[];
|
||||
maxCloneReferenceImages: number;
|
||||
@@ -94,7 +97,7 @@ interface EcommerceClonePanelProps {
|
||||
selectedCloneModelScenes: string[];
|
||||
cloneModelCustomScene: string;
|
||||
cloneModelSelects: CloneModelSelectItem[];
|
||||
openCloneModelSelect: string | null;
|
||||
openCloneModelSelect: CloneModelSelectKey | null;
|
||||
cloneModelSelectDropUp: boolean;
|
||||
cloneModelAppearance: string;
|
||||
cloneVideoQuality: CloneVideoQualityKey;
|
||||
@@ -102,27 +105,27 @@ interface EcommerceClonePanelProps {
|
||||
cloneVideoDuration: number;
|
||||
cloneVideoDurationMin: number;
|
||||
cloneVideoDurationMax: number;
|
||||
cloneVideoDurationStyle: { [key: string]: number | string };
|
||||
cloneVideoDurationStyle: CSSProperties;
|
||||
cloneVideoSmart: boolean;
|
||||
canGenerate: boolean;
|
||||
status: string;
|
||||
lastFailedActionRef: MutableRefObject<(() => void) | null>;
|
||||
setIsProductUploadDragging: (value: boolean) => void;
|
||||
handleProductDrop: (event: DragEvent<HTMLElement>) => void;
|
||||
handleProductDrop: (event: DragEvent<HTMLDivElement>) => void;
|
||||
removeProductImage: (id: string) => void;
|
||||
handleProductUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
handleCloneOutputChange: (value: CloneOutputKey) => void;
|
||||
setOpenCloneBasicSelect: (value: string | null) => void;
|
||||
setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void;
|
||||
setCloneReferenceMode: (value: CloneReferenceMode) => void;
|
||||
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
|
||||
startCloneSetCountHold: (key: CloneSetCountKey, delta: number, disabled: boolean) => void;
|
||||
startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void;
|
||||
clearCloneSetCountHold: () => void;
|
||||
toggleCloneDetailModule: (id: string) => void;
|
||||
setCloneModelPanelTab: (value: CloneModelPanelTab) => void;
|
||||
toggleCloneModelScene: (scene: string) => void;
|
||||
setCloneModelCustomScene: (value: string) => void;
|
||||
setOpenCloneModelSelect: (value: string | null) => void;
|
||||
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
|
||||
setCloneModelSelectDropUp: (value: boolean) => void;
|
||||
setCloneModelAppearance: (value: string) => void;
|
||||
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
|
||||
@@ -130,8 +133,10 @@ interface EcommerceClonePanelProps {
|
||||
clampCloneVideoDuration: (value: number) => number;
|
||||
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
|
||||
handleGenerate: () => void;
|
||||
onCancelGenerate: () => void;
|
||||
formatRatioDisplayValue: (value: string) => string;
|
||||
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
|
||||
onStartVideoPlan?: () => void;
|
||||
}
|
||||
|
||||
export default function EcommerceClonePanel({
|
||||
@@ -196,8 +201,10 @@ export default function EcommerceClonePanel({
|
||||
clampCloneVideoDuration,
|
||||
setCloneVideoSmart,
|
||||
handleGenerate,
|
||||
onCancelGenerate,
|
||||
formatRatioDisplayValue,
|
||||
setVideoOutfitFiles,
|
||||
onStartVideoPlan,
|
||||
}: EcommerceClonePanelProps) {
|
||||
const videoOutfitVideoRef = useRef<HTMLInputElement>(null);
|
||||
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
|
||||
@@ -666,15 +673,16 @@ export default function EcommerceClonePanel({
|
||||
type="range"
|
||||
min={cloneVideoDurationMin}
|
||||
max={cloneVideoDurationMax}
|
||||
step={1}
|
||||
step={5}
|
||||
value={cloneVideoDuration}
|
||||
onChange={(event) => setCloneVideoDuration(clampCloneVideoDuration(Number(event.target.value)))}
|
||||
aria-label="短视频时长"
|
||||
/>
|
||||
<div className="clone-ai-duration-scale" aria-hidden="true">
|
||||
<span>5秒</span>
|
||||
<span>10秒</span>
|
||||
<span>15秒</span>
|
||||
<span>30秒</span>
|
||||
<span>45秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -693,6 +701,12 @@ export default function EcommerceClonePanel({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "video" && onStartVideoPlan ? (
|
||||
<button type="button" className="clone-ai-generate" onClick={onStartVideoPlan}>
|
||||
✦ 一键策划
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{cloneOutput === "video-outfit" ? (
|
||||
<section className="clone-ai-video-panel" aria-label="视频换装">
|
||||
<div className="clone-ai-video-section">
|
||||
@@ -734,6 +748,11 @@ export default function EcommerceClonePanel({
|
||||
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
|
||||
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
|
||||
</button>
|
||||
{status === "generating" && cloneOutput !== "video" ? (
|
||||
<button type="button" className="clone-ai-generate clone-ai-generate--cancel" onClick={onCancelGenerate}>
|
||||
{"\u53d6\u6d88\u751f\u6210"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ interface EcommerceDetailPanelProps {
|
||||
handleDetailAiWrite: () => void;
|
||||
toggleDetailModule: (id: string) => void;
|
||||
handleDetailGenerate: () => void;
|
||||
onCancelGenerate: () => void;
|
||||
}
|
||||
|
||||
export default function EcommerceDetailPanel({
|
||||
@@ -56,6 +57,7 @@ export default function EcommerceDetailPanel({
|
||||
handleDetailAiWrite,
|
||||
toggleDetailModule,
|
||||
handleDetailGenerate,
|
||||
onCancelGenerate,
|
||||
}: EcommerceDetailPanelProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -162,6 +164,11 @@ export default function EcommerceDetailPanel({
|
||||
{detailStatus === "generating" ? <LoadingOutlined /> : null}
|
||||
{detailPrimaryLabel}
|
||||
</button>
|
||||
{detailStatus === "generating" ? (
|
||||
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
|
||||
{"\u53d6\u6d88\u751f\u6210"}
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { CloudUploadOutlined, CloseOutlined, FileImageOutlined, SettingOutlined } from "@ant-design/icons";
|
||||
import type { ChangeEvent, DragEvent, RefObject } from "react";
|
||||
|
||||
type ProductSetOutputKey = "set" | "detail" | "model" | "video";
|
||||
|
||||
interface EcommerceSetPanelProps {
|
||||
setInputRef: RefObject<HTMLInputElement>;
|
||||
setImages: Array<{ id: string; src: string; name: string }>;
|
||||
isSetUploadDragging: boolean;
|
||||
productSetOutputOptions: Array<{ key: string; label: string }>;
|
||||
productSetOutput: string;
|
||||
productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string }>;
|
||||
productSetOutput: ProductSetOutputKey;
|
||||
platformOptions: string[];
|
||||
marketOptions: string[];
|
||||
productSetLanguageOptions: string[];
|
||||
@@ -16,10 +18,10 @@ interface EcommerceSetPanelProps {
|
||||
productSetLanguage: string;
|
||||
productSetRatio: string;
|
||||
setIsSetUploadDragging: (value: boolean) => void;
|
||||
handleSetDrop: (event: DragEvent<HTMLElement>) => void;
|
||||
handleSetDrop: (event: DragEvent<HTMLButtonElement>) => void;
|
||||
handleSetUpload: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
removeSetImage: (id: string) => void;
|
||||
handleProductSetOutputChange: (value: string) => void;
|
||||
handleProductSetOutputChange: (value: ProductSetOutputKey) => void;
|
||||
handleProductSetPlatformChange: (value: string) => void;
|
||||
handleProductSetMarketChange: (value: string) => void;
|
||||
setProductSetLanguage: (value: string) => void;
|
||||
|
||||
@@ -35,6 +35,7 @@ interface EcommerceTryOnPanelProps {
|
||||
setSmartScene: (updater: (current: boolean) => boolean) => void;
|
||||
setTryOnRatio: (value: string) => void;
|
||||
handleTryOnGenerate: () => void;
|
||||
onCancelGenerate: () => void;
|
||||
}
|
||||
|
||||
export default function EcommerceTryOnPanel({
|
||||
@@ -70,6 +71,7 @@ export default function EcommerceTryOnPanel({
|
||||
setSmartScene,
|
||||
setTryOnRatio,
|
||||
handleTryOnGenerate,
|
||||
onCancelGenerate,
|
||||
}: EcommerceTryOnPanelProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -213,6 +215,11 @@ export default function EcommerceTryOnPanel({
|
||||
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
|
||||
{tryOnPrimaryLabel}
|
||||
</button>
|
||||
{tryOnStatus === "generating" ? (
|
||||
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
|
||||
{"\u53d6\u6d88\u751f\u6210"}
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
HistoryOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
fetchVideoHistory,
|
||||
deleteVideoHistory,
|
||||
type VideoHistoryItem,
|
||||
} from "../ecommerceVideoService";
|
||||
|
||||
interface EcommerceVideoHistoryPanelProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EcommerceVideoHistoryPanel({
|
||||
visible,
|
||||
onClose,
|
||||
}: EcommerceVideoHistoryPanelProps) {
|
||||
const [items, setItems] = useState<VideoHistoryItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [previewMedia, setPreviewMedia] = useState<{
|
||||
url: string;
|
||||
type: "image" | "video";
|
||||
} | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
|
||||
const limit = 10;
|
||||
|
||||
const load = useCallback(async (off: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchVideoHistory(limit, off);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setOffset(off);
|
||||
} catch { /* silent */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) load(0);
|
||||
}, [visible, load]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteVideoHistory(id);
|
||||
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||
setTotal((t) => t - 1);
|
||||
} catch { /* silent */ }
|
||||
setConfirmDeleteId(null);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ecom-video-history-panel">
|
||||
<div className="ecom-video-history-panel__header">
|
||||
<HistoryOutlined />
|
||||
<span>生成记录</span>
|
||||
<button className="ecom-video-history-panel__close" onClick={onClose}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ecom-video-history-panel__body">
|
||||
{loading && !items.length ? (
|
||||
<div className="ecom-video-history-panel__empty">
|
||||
<LoadingOutlined style={{ fontSize: 24 }} />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : !items.length ? (
|
||||
<div className="ecom-video-history-panel__empty">
|
||||
<HistoryOutlined style={{ fontSize: 32, opacity: 0.3 }} />
|
||||
<span>暂无生成记录</span>
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className="ecom-video-history-card">
|
||||
<div className="ecom-video-history-card__header">
|
||||
<span className="ecom-video-history-card__title">
|
||||
{item.title || "未命名"}
|
||||
</span>
|
||||
<span className="ecom-video-history-card__date">
|
||||
{new Date(item.createdAt).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
<button
|
||||
className="ecom-video-history-card__delete"
|
||||
onClick={() => setConfirmDeleteId(item.id)}
|
||||
title="删除"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="ecom-video-history-card__scenes">
|
||||
{item.scenes.map((scene, idx) => (
|
||||
<div key={idx} className="ecom-video-history-card__scene">
|
||||
{scene.imageUrl && (
|
||||
<img
|
||||
src={scene.imageUrl}
|
||||
alt={`分镜${idx + 1}`}
|
||||
onClick={() =>
|
||||
setPreviewMedia({ url: scene.imageUrl!, type: "image" })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{scene.videoUrl && (
|
||||
<div
|
||||
className="ecom-video-history-card__video-thumb"
|
||||
onClick={() =>
|
||||
setPreviewMedia({ url: scene.videoUrl!, type: "video" })
|
||||
}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="ecom-video-history-panel__pager">
|
||||
<button disabled={currentPage <= 1} onClick={() => load(offset - limit)}>
|
||||
上一页
|
||||
</button>
|
||||
<span>{currentPage}/{totalPages}</span>
|
||||
<button disabled={currentPage >= totalPages} onClick={() => load(offset + limit)}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmDeleteId !== null && (
|
||||
<div className="ecom-video-confirm-dialog-backdrop" onClick={() => setConfirmDeleteId(null)}>
|
||||
<div className="ecom-video-confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<ExclamationCircleOutlined className="ecom-video-confirm-dialog__icon" />
|
||||
<p className="ecom-video-confirm-dialog__text">
|
||||
确定要删除这条记录吗?相关的图片和视频文件也将被永久删除,此操作不可恢复。
|
||||
</p>
|
||||
<div className="ecom-video-confirm-dialog__actions">
|
||||
<button onClick={() => setConfirmDeleteId(null)}>取消</button>
|
||||
<button className="is-danger" onClick={() => handleDelete(confirmDeleteId)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewMedia && (
|
||||
<div
|
||||
className="ecom-video-preview-overlay"
|
||||
onClick={() => setPreviewMedia(null)}
|
||||
>
|
||||
<button
|
||||
className="ecom-video-preview-overlay__close"
|
||||
onClick={() => setPreviewMedia(null)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
{previewMedia.type === "image" ? (
|
||||
<img src={previewMedia.url} alt="preview" />
|
||||
) : (
|
||||
<video src={previewMedia.url} controls autoPlay />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||
import { useScrollEntrance } from "../../hooks/useScrollEntrance";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import WelcomeSplash from "./WelcomeSplash";
|
||||
import ToolboxSection from "./ToolboxSection";
|
||||
import ScriptReviewShowcase from "./ScriptReviewShowcase";
|
||||
@@ -24,13 +25,12 @@ function ScrollEntrance({ children, className, ...rest }: { children: React.Reac
|
||||
);
|
||||
}
|
||||
|
||||
const OSS_MUBAN = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban";
|
||||
const heroImage1 = `${OSS_MUBAN}/hero-1.png`;
|
||||
const heroImage2 = `${OSS_MUBAN}/hero-2.png`;
|
||||
const heroImage3 = `${OSS_MUBAN}/hero-3.png`;
|
||||
const featureEcommerceImage = `${OSS_MUBAN}/feature-ecommerce.jpg`;
|
||||
const featureScriptImage = `${OSS_MUBAN}/feature-script.jpg`;
|
||||
const featureTokenImage = `${OSS_MUBAN}/feature-token.jpg`;
|
||||
const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
|
||||
const {
|
||||
ecommerce: featureEcommerceImage,
|
||||
script: featureScriptImage,
|
||||
token: featureTokenImage,
|
||||
} = ossAssets.home.features;
|
||||
|
||||
interface HomePageProps {
|
||||
onOpenGenerate: () => void;
|
||||
@@ -42,7 +42,7 @@ interface HomePageProps {
|
||||
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
|
||||
}
|
||||
|
||||
const HOME_BACKGROUND_VIDEO = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban/hero-bg.mp4";
|
||||
const HOME_BACKGROUND_VIDEO = ossAssets.home.backgroundVideo;
|
||||
|
||||
const HOME_CAROUSEL_IMAGES = [
|
||||
{ imageUrl: heroImage1, title: "灵感生成" },
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ToolOutlined } from "@ant-design/icons";
|
||||
import type { WebViewKey, WebImageWorkbenchTool } from "../../types";
|
||||
import toolImageBefore from "../../assets/toolbox/牛仔.png";
|
||||
import toolImageAfter from "../../assets/toolbox/西装.png";
|
||||
import watermarkBefore from "../../assets/toolbox/去水印前.png";
|
||||
import watermarkAfter from "../../assets/toolbox/去水印后.png";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
|
||||
const {
|
||||
imageBefore: toolImageBefore,
|
||||
imageAfter: toolImageAfter,
|
||||
watermarkBefore,
|
||||
watermarkAfter,
|
||||
} = ossAssets.toolbox;
|
||||
|
||||
interface ToolboxSectionProps {
|
||||
onSelectView: (view: WebViewKey) => void;
|
||||
|
||||
@@ -148,22 +148,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
keepaliveRestoredRef.current = true;
|
||||
const saved = loadToolTaskState("imagewb");
|
||||
if (!saved || saved.resultUrl) return;
|
||||
setIsGenerating(true);
|
||||
setGenerating(true);
|
||||
abortRef.current = false;
|
||||
taskIdRef.current = saved.taskId;
|
||||
void waitForTask(saved.taskId, {
|
||||
onProgress: (e) => {
|
||||
setTaskProgress(Math.max(0, Math.min(100, Math.trunc(e.progress || 0))));
|
||||
setStatus(`${e.status} / ${e.progress}%`);
|
||||
if (e.status === "completed" && e.resultUrl) {
|
||||
setResultImages([e.resultUrl]);
|
||||
clearToolTaskState("imagewb");
|
||||
setIsGenerating(false);
|
||||
setGenerating(false);
|
||||
setStatus("恢复任务完成");
|
||||
}
|
||||
if (e.status === "failed") {
|
||||
clearToolTaskState("imagewb");
|
||||
setIsGenerating(false);
|
||||
setGenerating(false);
|
||||
setStatus("恢复任务失败");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
HighlightOutlined,
|
||||
MessageOutlined,
|
||||
SwapOutlined,
|
||||
ThunderboltOutlined,
|
||||
VideoCameraOutlined,
|
||||
@@ -42,6 +43,7 @@ const tools: MoreTool[] = [
|
||||
{ id: "camera", title: "镜头实验室", text: "角度、焦段和机位控制", icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
|
||||
{ id: "upscale", title: "分辨率提升", text: "图片与视频高清超分", icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
|
||||
{ id: "watermarkRemoval", title: "去水印", text: "AI 智能去除图片水印和文字", icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
|
||||
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,添加可拖拽编辑的对话框", icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
|
||||
{ id: "subtitleRemoval", title: "字幕去除", text: "AI 智能擦除视频字幕", icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
|
||||
{ id: "digitalHuman", title: "数字人", text: "参考人像与音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
|
||||
{ id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FileImageOutlined,
|
||||
FolderOpenOutlined,
|
||||
LockOutlined,
|
||||
MailOutlined,
|
||||
MobileOutlined,
|
||||
PhoneOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
SafetyOutlined,
|
||||
ShareAltOutlined,
|
||||
@@ -20,6 +23,7 @@ import { assetClient } from "../../api/assetClient";
|
||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||
import { keyServerClient } from "../../api/keyServerClient";
|
||||
import { isServerRequestError } from "../../api/serverConnection";
|
||||
import { ossAssets } from "../../data/ossAssets";
|
||||
import type { WebAuthMode, WebGenerationPreviewTask, WebProjectSummary, WebUsageSummary, WebUserSession } from "../../types";
|
||||
import type { SavedAssetItem } from "../assets/localAssetStore";
|
||||
|
||||
@@ -44,8 +48,8 @@ type ProfilePanel = "works" | "projects" | "assets" | "community";
|
||||
type AccountPanel = "credits" | "tasks";
|
||||
|
||||
const PROFILE_LOCAL_STORAGE_PREFIX = "omniai-web-profile-ui";
|
||||
const AUTH_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
|
||||
const AUTH_SHOWCASE_VIDEO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/test5.mp4";
|
||||
const AUTH_LOGO_URL = ossAssets.brand.logo;
|
||||
const AUTH_SHOWCASE_VIDEO_URL = ossAssets.auth.showcaseVideo;
|
||||
|
||||
function profileStorageKey(userId: string | number | undefined, field: "avatar" | "bio" | "background"): string {
|
||||
return `${PROFILE_LOCAL_STORAGE_PREFIX}:${userId ?? "guest"}:${field}`;
|
||||
@@ -179,6 +183,19 @@ function formatAssetStatus(status: string | undefined): string {
|
||||
return status || "资产";
|
||||
}
|
||||
|
||||
function formatAssetType(type: SavedAssetItem["type"]): string {
|
||||
const labels: Record<string, string> = {
|
||||
character: "角色",
|
||||
scene: "场景",
|
||||
prop: "道具",
|
||||
video: "视频",
|
||||
image: "图像",
|
||||
asset: "资产",
|
||||
other: "素材",
|
||||
};
|
||||
return labels[type] || "素材";
|
||||
}
|
||||
|
||||
function ProfilePage({
|
||||
session,
|
||||
usage,
|
||||
@@ -607,22 +624,50 @@ function ProfilePage({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderCardPreview = (
|
||||
url: string | null | undefined,
|
||||
type: "image" | "video" | "project" | "asset",
|
||||
label: string,
|
||||
) => {
|
||||
const mediaUrl = typeof url === "string" ? url.trim() : "";
|
||||
const isVideoPreview = type === "video" || /\.(mp4|webm|mov)(\?|#|$)/i.test(mediaUrl);
|
||||
const placeholderIcon =
|
||||
type === "video" ? <PlayCircleOutlined /> : type === "project" ? <FolderOpenOutlined /> : <FileImageOutlined />;
|
||||
|
||||
return (
|
||||
<div className={`profile-page__list-card-preview${mediaUrl ? " has-media" : ""}`} aria-hidden="true">
|
||||
{mediaUrl ? (
|
||||
isVideoPreview ? (
|
||||
<video src={mediaUrl} muted playsInline preload="metadata" />
|
||||
) : (
|
||||
<img src={mediaUrl} alt="" loading="lazy" />
|
||||
)
|
||||
) : (
|
||||
<span className="profile-page__list-card-placeholder">{placeholderIcon}</span>
|
||||
)}
|
||||
<span className="profile-page__media-badge">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderActivePanel = () => {
|
||||
if (activePanel === "works") {
|
||||
return visibleWorks.length ? (
|
||||
<div className="profile-page__works-scroll">
|
||||
<div className="profile-page__list-grid motion-stagger">
|
||||
{visibleWorks.map((task) => (
|
||||
<article key={task.id} className="profile-page__list-card">
|
||||
<article key={task.id} className="profile-page__list-card profile-page__media-card">
|
||||
{renderCardPreview(task.outputUrl, task.type === "video" ? "video" : "image", formatTaskType(task.type))}
|
||||
<div className="profile-page__list-card-body">
|
||||
<div className="profile-page__list-card-head">
|
||||
<strong>{task.title}</strong>
|
||||
<span>{formatTaskType(task.type)}</span>
|
||||
</div>
|
||||
<p>{task.prompt}</p>
|
||||
<div className="profile-page__list-card-meta">
|
||||
<span>{formatTaskStatus(task.status)}</span>
|
||||
<span>{formatProfileDate(task.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -636,10 +681,11 @@ function ProfilePage({
|
||||
return projects.length ? (
|
||||
<div className="profile-page__list-grid motion-stagger">
|
||||
{projects.map((project) => (
|
||||
<article key={project.id} className="profile-page__list-card">
|
||||
<article key={project.id} className="profile-page__list-card profile-page__media-card">
|
||||
{renderCardPreview(project.thumbnailUrl, "project", "项目")}
|
||||
<div className="profile-page__list-card-body">
|
||||
<div className="profile-page__list-card-head">
|
||||
<strong>{project.name}</strong>
|
||||
<span>{formatProfileDate(project.updatedAt)}</span>
|
||||
{onDeleteProject ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -654,7 +700,8 @@ function ProfilePage({
|
||||
<p>{project.description || "最近更新的项目"}</p>
|
||||
<div className="profile-page__list-card-meta">
|
||||
<span>{project.storyboardCount} 节点</span>
|
||||
<span>{project.imageCount} 图 / {project.videoCount} 视频</span>
|
||||
<span>{formatProfileDate(project.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -668,16 +715,19 @@ function ProfilePage({
|
||||
return savedAssets.length ? (
|
||||
<div className="profile-page__list-grid">
|
||||
{savedAssets.map((asset) => (
|
||||
<article key={asset.id} className="profile-page__list-card">
|
||||
<article key={asset.id} className="profile-page__list-card profile-page__media-card">
|
||||
{renderCardPreview(asset.imageUrl || asset.url, asset.type === "video" ? "video" : "asset", formatAssetType(asset.type))}
|
||||
<div className="profile-page__list-card-body">
|
||||
<div className="profile-page__list-card-head">
|
||||
<strong>{asset.name}</strong>
|
||||
<span>{formatAssetStatus(asset.status)}</span>
|
||||
</div>
|
||||
<p>{asset.description}</p>
|
||||
<div className="profile-page__list-card-meta">
|
||||
<span>{asset.type}</span>
|
||||
<span>{formatAssetType(asset.type)}</span>
|
||||
<span>{formatProfileDate(asset.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -790,6 +840,50 @@ function ProfilePage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-page__account-card">
|
||||
<div className="profile-page__list-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={accountPanel === "credits" ? "is-active" : ""}
|
||||
onClick={() => setAccountPanel("credits")}
|
||||
>
|
||||
积分 {(totalBalance / 100).toFixed(2)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={accountPanel === "tasks" ? "is-active" : ""}
|
||||
onClick={() => setAccountPanel("tasks")}
|
||||
>
|
||||
任务 {tasks.length}
|
||||
</button>
|
||||
</div>
|
||||
<div className="profile-page__account-summary">
|
||||
{accountPanel === "credits" ? (
|
||||
<>
|
||||
<span className="profile-page__account-summary-main">
|
||||
<small>当前账号</small>
|
||||
<strong>{displayName}</strong>
|
||||
</span>
|
||||
<span className="profile-page__account-summary-metric">
|
||||
<small>积分剩余</small>
|
||||
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="profile-page__account-summary-main">
|
||||
<small>任务概览</small>
|
||||
<strong>{tasks.length} 个任务</strong>
|
||||
</span>
|
||||
<span className="profile-page__account-summary-metric">
|
||||
<small>已完成</small>
|
||||
<strong>{completedTasks.length}</strong>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
|
||||
<ShareAltOutlined />
|
||||
{packageLabel}
|
||||
@@ -837,52 +931,6 @@ function ProfilePage({
|
||||
</span>
|
||||
{renderActivePanel()}
|
||||
</div>
|
||||
|
||||
<div className="profile-page__section">
|
||||
<div className="profile-page__list-bar">
|
||||
<div className="profile-page__list-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={accountPanel === "credits" ? "is-active" : ""}
|
||||
onClick={() => setAccountPanel("credits")}
|
||||
>
|
||||
积分 {(totalBalance / 100).toFixed(2)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={accountPanel === "tasks" ? "is-active" : ""}
|
||||
onClick={() => setAccountPanel("tasks")}
|
||||
>
|
||||
任务 {tasks.length}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-page__upload-card profile-page__upload-card--meta">
|
||||
{accountPanel === "credits" ? (
|
||||
<>
|
||||
<span className="profile-page__meta-item">
|
||||
<small>当前账号</small>
|
||||
<strong>{displayName}</strong>
|
||||
</span>
|
||||
<span className="profile-page__meta-item">
|
||||
<small>积分剩余</small>
|
||||
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="profile-page__meta-item">
|
||||
<small>任务总数</small>
|
||||
<strong>{tasks.length}</strong>
|
||||
</span>
|
||||
<span className="profile-page__meta-item">
|
||||
<small>已完成</small>
|
||||
<strong>{completedTasks.length}</strong>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "@ant-design/icons";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
|
||||
import { reportClient, type ReportInput } from "../../api/reportClient";
|
||||
|
||||
type SubmitState = "idle" | "loading" | "success" | "error";
|
||||
@@ -31,6 +32,7 @@ function ReportPage() {
|
||||
const [contactPhone, setContactPhone] = useState("");
|
||||
const [submitState, setSubmitState] = useState<SubmitState>("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
|
||||
|
||||
const canSubmit =
|
||||
submitState !== "loading" && reportType !== "" && title.trim() !== "" && description.trim() !== "";
|
||||
@@ -48,6 +50,22 @@ function ReportPage() {
|
||||
setErrorMsg("");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
publicConfigClient
|
||||
.get()
|
||||
.then((config) => {
|
||||
if (!cancelled) setPublicConfig(config);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPublicConfig({});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
@@ -85,9 +103,9 @@ function ReportPage() {
|
||||
</header>
|
||||
|
||||
<div className="report-contact-strip">
|
||||
<span><MailOutlined /> {import.meta.env.VITE_REPORT_EMAIL || "support@omniai.com"}</span>
|
||||
<span><PhoneOutlined /> {import.meta.env.VITE_REPORT_PHONE || "请在环境变量配置客服电话"}</span>
|
||||
<span>{import.meta.env.VITE_ICP_RECORD || "ICP备案信息待配置"}</span>
|
||||
<span><MailOutlined /> {publicConfig.contactEmail || "由服务器配置"}</span>
|
||||
<span><PhoneOutlined /> {publicConfig.contactPhone || "由服务器配置"}</span>
|
||||
<span>{publicConfig.icpRecord || "由服务器配置"}</span>
|
||||
</div>
|
||||
|
||||
{submitState === "success" ? (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import { evaluateScript } from "../../api/scriptEvalClient";
|
||||
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
|
||||
import { useSessionStore } from "../../stores";
|
||||
|
||||
interface ScoreDimension {
|
||||
@@ -24,6 +25,8 @@ interface EvalResult {
|
||||
totalScore: number;
|
||||
grade: string;
|
||||
dimensionScores: Record<string, number>;
|
||||
subScores?: Record<string, Record<string, number>>;
|
||||
evidence?: Record<string, string[]>;
|
||||
summary: string;
|
||||
issues: string[];
|
||||
highlights: string[];
|
||||
@@ -175,61 +178,6 @@ function normalizeUploadedText(raw: string, ext: string): string {
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function extractDocxText(bytes: Uint8Array): Promise<string> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const entries: Array<{ name: string; offset: number; size: number; compressed: boolean }> = [];
|
||||
let pos = 0;
|
||||
while (pos < bytes.length - 30) {
|
||||
if (view.getUint32(pos, true) !== 0x04034b50) break;
|
||||
const compressed = view.getUint16(pos + 10, true) !== 0;
|
||||
const compressedSize = view.getUint32(pos + 18, true);
|
||||
const fileNameLen = view.getUint16(pos + 26, true);
|
||||
const extraLen = view.getUint16(pos + 28, true);
|
||||
const name = new TextDecoder().decode(bytes.slice(pos + 30, pos + 30 + fileNameLen));
|
||||
const dataStart = pos + 30 + fileNameLen + extraLen;
|
||||
entries.push({ name, offset: dataStart, size: compressedSize, compressed });
|
||||
pos = dataStart + compressedSize;
|
||||
}
|
||||
const docEntry = entries.find((e) => e.name === "word/document.xml");
|
||||
if (!docEntry) return "";
|
||||
const xmlBytes = bytes.slice(docEntry.offset, docEntry.offset + docEntry.size);
|
||||
let xmlText: string;
|
||||
if (docEntry.compressed) {
|
||||
try {
|
||||
const ds = new DecompressionStream("deflate-raw");
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(xmlBytes);
|
||||
writer.close();
|
||||
const reader = ds.readable.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
||||
const combined = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const c of chunks) { combined.set(c, offset); offset += c.length; }
|
||||
xmlText = new TextDecoder().decode(combined);
|
||||
} catch {
|
||||
xmlText = new TextDecoder().decode(xmlBytes);
|
||||
}
|
||||
} else {
|
||||
xmlText = new TextDecoder().decode(xmlBytes);
|
||||
}
|
||||
const textMatches = xmlText.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
||||
if (!textMatches) return "";
|
||||
const paraMatches = xmlText.match(/<w:p[ >][\s\S]*?<\/w:p>/g);
|
||||
if (paraMatches) {
|
||||
return paraMatches.map((p) => {
|
||||
const tMatches = p.match(/<w:t[^>]*>([\s\S]*?)<\/w:t>/g);
|
||||
if (!tMatches) return "";
|
||||
return tMatches.map((m) => m.replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, "\"")).join("");
|
||||
}).filter(Boolean).join("\n").trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatFileSize(size: number): string {
|
||||
if (size < 1024) return `${size} B`;
|
||||
@@ -246,6 +194,60 @@ const SCORE_DIMENSIONS: ScoreDimension[] = [
|
||||
{ key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" },
|
||||
];
|
||||
|
||||
const SUB_SCORE_LABELS: Record<string, string> = {
|
||||
openingImpact: "开篇冲击",
|
||||
suspenseChain: "悬念链",
|
||||
sceneHook: "场内钩子",
|
||||
structure: "结构完整",
|
||||
rhythm: "节奏推进",
|
||||
conflict: "冲突强度",
|
||||
reversal: "反转效率",
|
||||
motivation: "动机清晰",
|
||||
arc: "人物弧光",
|
||||
voice: "语言辨识",
|
||||
relationship: "关系张力",
|
||||
causality: "因果链",
|
||||
worldRules: "世界规则",
|
||||
foreshadowing: "伏笔回收",
|
||||
continuity: "连续性",
|
||||
sceneDetail: "场景细节",
|
||||
shotPotential: "镜头潜力",
|
||||
aigcFeasibility: "AIGC 可实现",
|
||||
theme: "主题表达",
|
||||
emotion: "情感共鸣",
|
||||
marketFit: "市场匹配",
|
||||
originality: "原创性",
|
||||
};
|
||||
|
||||
function clampScore(score: unknown, maxScore: number): number {
|
||||
const numeric = Number(score);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.min(maxScore, numeric));
|
||||
}
|
||||
|
||||
function getDimensionScore(result: EvalResult, dim: ScoreDimension): number {
|
||||
const value = result.dimensionScores[dim.key] ?? (dim.key === "logic" ? result.dimensionScores.dialogue : undefined);
|
||||
return clampScore(value, dim.maxScore);
|
||||
}
|
||||
|
||||
function formatSubScoreLabel(key: string): string {
|
||||
return SUB_SCORE_LABELS[key] ?? key.replace(/([A-Z])/g, " $1").trim();
|
||||
}
|
||||
|
||||
function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[string, number]> {
|
||||
const scores = result.subScores?.[dim.key] ?? (dim.key === "logic" ? result.subScores?.dialogue : undefined);
|
||||
if (!scores) return [];
|
||||
return Object.entries(scores)
|
||||
.map(([key, value]) => [key, clampScore(value, dim.maxScore)] as [string, number])
|
||||
.filter(([, value]) => value > 0)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
|
||||
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
|
||||
return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : [];
|
||||
}
|
||||
|
||||
function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# 剧本评测报告`);
|
||||
@@ -257,9 +259,16 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||
lines.push("");
|
||||
lines.push(`## 六维评分`);
|
||||
for (const dim of SCORE_DIMENSIONS) {
|
||||
const score = result.dimensionScores[dim.key] ?? 0;
|
||||
const score = getDimensionScore(result, dim);
|
||||
const pct = Math.round((score / dim.maxScore) * 100);
|
||||
const subScores = getDimensionSubScores(result, dim);
|
||||
const evidence = getDimensionEvidence(result, dim);
|
||||
const nestedReportLines = [
|
||||
...subScores.map(([key, value]) => ` - ${formatSubScoreLabel(key)}: ${value}`),
|
||||
...evidence.map((item) => ` - 证据: ${item}`),
|
||||
];
|
||||
lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
|
||||
lines.push(...nestedReportLines);
|
||||
}
|
||||
if (result.highlights.length > 0) {
|
||||
lines.push("");
|
||||
@@ -321,22 +330,26 @@ function ScriptTokensPage() {
|
||||
const ext = getFileExtension(file.name);
|
||||
const readable = isReadableTextFile(file, ext);
|
||||
setUploadedFile({ name: file.name, size: file.size });
|
||||
if (ext === ".docx") {
|
||||
if (ext === ".docx" || ext === ".doc") {
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const text = await extractDocxText(bytes);
|
||||
if (text) {
|
||||
setScript(text);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const token = getStoredToken();
|
||||
const resp = await fetch(buildApiUrl("files/extract-text"), {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (resp.ok) {
|
||||
const { text } = await resp.json();
|
||||
setScript(text || "");
|
||||
} else {
|
||||
setScript(`[已上传文件:${file.name}]\n\n无法从 DOCX 文件中提取文本,请尝试另存为 TXT 格式后重新上传。`);
|
||||
const err = await resp.json().catch(() => ({ error: "解析失败" }));
|
||||
setScript(`[已上传文件:${file.name}]\n\n${err.error || "文件解析失败,请尝试另存为 TXT 格式后重新上传。"}`);
|
||||
}
|
||||
} catch {
|
||||
setScript(`[已上传文件:${file.name}]\n\n解析 DOCX 文件失败,请尝试另存为 TXT 格式后重新上传。`);
|
||||
setScript(`[已上传文件:${file.name}]\n\n文件解析请求失败,请检查网络连接后重试。`);
|
||||
}
|
||||
} else if (ext === ".doc") {
|
||||
const text = await decodeTextFile(file);
|
||||
const cleaned = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/\s{3,}/g, "\n\n").trim();
|
||||
setScript(cleaned || `[已上传文件:${file.name}]\n\n无法从 .doc 文件中提取文本,请另存为 .docx 或 .txt 格式。`);
|
||||
} else if (readable) {
|
||||
const text = normalizeUploadedText(await decodeTextFile(file), ext);
|
||||
setScript(text);
|
||||
@@ -455,6 +468,7 @@ function ScriptTokensPage() {
|
||||
<div className="script-eval-v5-page">
|
||||
{/* Left Panel */}
|
||||
<aside className="script-eval-v5-left">
|
||||
<div className="script-eval-v5-left-main">
|
||||
<div className="script-eval-v5-lp-section">
|
||||
<div className="script-eval-v5-lp-label">上传剧本</div>
|
||||
<div
|
||||
@@ -561,6 +575,7 @@ function ScriptTokensPage() {
|
||||
<span>导出评测报告</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right Area */}
|
||||
@@ -684,7 +699,7 @@ function ScriptTokensPage() {
|
||||
</div>
|
||||
<div className="script-eval-report__chart-grid">
|
||||
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
|
||||
const score = result.dimensionScores[dim.key] ?? 0;
|
||||
const score = getDimensionScore(result, dim);
|
||||
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
|
||||
const lossPct = 1 - pct;
|
||||
const isPerfect = score === dim.maxScore;
|
||||
@@ -724,6 +739,51 @@ function ScriptTokensPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="script-eval-report__detail-grid">
|
||||
{SCORE_DIMENSIONS.map((dim) => {
|
||||
const score = getDimensionScore(result, dim);
|
||||
const pct = Math.round((score / dim.maxScore) * 100);
|
||||
const subScores = getDimensionSubScores(result, dim);
|
||||
const evidence = getDimensionEvidence(result, dim);
|
||||
|
||||
return (
|
||||
<section className="script-eval-report__detail-card" key={dim.key}>
|
||||
<header className="script-eval-report__detail-head">
|
||||
<div>
|
||||
<span>{dim.label}</span>
|
||||
<strong>{score}<small>/{dim.maxScore}</small></strong>
|
||||
</div>
|
||||
<em>{pct}%</em>
|
||||
</header>
|
||||
<p className="script-eval-report__detail-hint">{dim.hint}</p>
|
||||
{subScores.length > 0 ? (
|
||||
<div className="script-eval-report__subscore-list">
|
||||
{subScores.map(([key, value]) => {
|
||||
const subPct = Math.max(0, Math.min(100, Math.round((value / dim.maxScore) * 100)));
|
||||
return (
|
||||
<div className="script-eval-report__subscore-row" key={key}>
|
||||
<span>{formatSubScoreLabel(key)}</span>
|
||||
<div className="script-eval-report__subscore-bar" aria-hidden="true">
|
||||
<i style={{ width: `${subPct}%` }} />
|
||||
</div>
|
||||
<b>{value}</b>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="script-eval-report__detail-empty">等待模型返回更细的子项评分;当前先按主维度分数展示。</p>
|
||||
)}
|
||||
{evidence.length > 0 ? (
|
||||
<ul className="script-eval-report__evidence-list">
|
||||
{evidence.map((item, index) => <li key={index}>{item}</li>)}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="script-eval-report__findings">
|
||||
{result.highlights.length > 0 ? (
|
||||
<section className="script-eval-report__finding-group is-highlight">
|
||||
|
||||
@@ -271,9 +271,8 @@ function TokenUsagePage({
|
||||
) : null}
|
||||
|
||||
<section className="management-metric-cards" aria-label="关键指标">
|
||||
{metricCards.map((card, index) => (
|
||||
{metricCards.map((card) => (
|
||||
<article key={card.key} className={`management-metric-card is-${card.tone}`}>
|
||||
<span className="management-metric-card__index">{String(index + 1).padStart(2, "0")}</span>
|
||||
<span className="management-metric-card__label">{card.label}</span>
|
||||
<strong className="management-metric-card__value">{card.value}</strong>
|
||||
<span className="management-metric-card__hint">{card.hint}</span>
|
||||
|
||||
@@ -64,6 +64,12 @@ import {
|
||||
import { renderMarkdownBlocks } from "./markdownRenderer";
|
||||
import { downloadResultAsset } from "./workbenchDownload";
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
formatTextTokenUsage,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../../utils/taskLifecycle";
|
||||
import { detectMentionTrigger } from "../../utils/mentionTrigger";
|
||||
import {
|
||||
isHappyHorseModel,
|
||||
@@ -103,6 +109,7 @@ import {
|
||||
VIDEO_MODEL_OPTIONS,
|
||||
RATIO_OPTIONS,
|
||||
GRID_MODE_OPTIONS,
|
||||
GRID_SUPPORTED_MODELS,
|
||||
VIDEO_FRAME_OPTIONS,
|
||||
VIDEO_DURATION_OPTIONS,
|
||||
MESSAGE_STORAGE_KEY,
|
||||
@@ -250,6 +257,8 @@ function WorkbenchPage({
|
||||
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
|
||||
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
|
||||
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
|
||||
const [isComposerDragging, setIsComposerDragging] = useState(false);
|
||||
const composerDragCounterRef = useRef(0);
|
||||
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
|
||||
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
|
||||
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
|
||||
@@ -863,6 +872,9 @@ function WorkbenchPage({
|
||||
|
||||
let lastKnownProgress = Math.max(0, Number(task.progress || 0));
|
||||
let taskPollFailures = 0;
|
||||
let lastProgressAt = task.startedAt || Date.now();
|
||||
const taskKind = task.mode === "image" ? "image" : "video";
|
||||
const timeoutPolicy = getTaskTimeoutPolicy({ kind: taskKind, model: task.modelLabel, operation: task.operation });
|
||||
const abortController = new AbortController();
|
||||
taskAbortControllersRef.current.set(task.taskId, abortController);
|
||||
if (activeConversationIdRef.current === task.conversationId) {
|
||||
@@ -909,6 +921,9 @@ function WorkbenchPage({
|
||||
const progress = status.status === "completed"
|
||||
? 100
|
||||
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress)));
|
||||
if (progress > lastKnownProgress || status.status === "completed") {
|
||||
lastProgressAt = Date.now();
|
||||
}
|
||||
lastKnownProgress = Math.max(lastKnownProgress, progress);
|
||||
const isSuperResolveTask = task.operation === "video-super-resolution";
|
||||
const statusLabel =
|
||||
@@ -933,6 +948,28 @@ function WorkbenchPage({
|
||||
setGenerationProgress(progress);
|
||||
}
|
||||
|
||||
const localTimeoutReason = status.status !== "completed" && status.status !== "failed" && status.status !== "cancelled"
|
||||
? isTaskLocallyTimedOut({
|
||||
startedAt: task.startedAt || Date.now(),
|
||||
lastProgressAt,
|
||||
progress,
|
||||
policy: timeoutPolicy,
|
||||
})
|
||||
: null;
|
||||
if (localTimeoutReason) {
|
||||
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
|
||||
body: buildLocalTimeoutMessage(taskKind),
|
||||
status: "local_timeout",
|
||||
taskLifecycleStatus: "local_timeout",
|
||||
taskRefundStatus: "unknown",
|
||||
taskProgress: progress,
|
||||
taskStatusLabel: "本地等待超时",
|
||||
});
|
||||
removeKeepaliveTask(task.taskId);
|
||||
onRefreshUsage?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === "completed" && status.resultUrl) {
|
||||
const completedPatch: Partial<ChatMessage> = {
|
||||
body: isSuperResolveTask
|
||||
@@ -999,11 +1036,6 @@ function WorkbenchPage({
|
||||
});
|
||||
removeKeepaliveTask(task.taskId);
|
||||
onRefreshUsage?.();
|
||||
if (status.status === "completed") {
|
||||
import("../../utils/generationNotifier").then((m) =>
|
||||
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1464,9 +1496,22 @@ function WorkbenchPage({
|
||||
setReferenceItems(nextItems);
|
||||
};
|
||||
|
||||
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
const handleReferenceUploadClick = () => {
|
||||
if (referenceItems.length > 0) {
|
||||
setToolbarMenuId(null);
|
||||
setReferencePreviewOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
referenceInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleReferenceAddMore = () => {
|
||||
setToolbarMenuId(null);
|
||||
setReferencePreviewOpen(true);
|
||||
referenceInputRef.current?.click();
|
||||
};
|
||||
|
||||
const processReferenceFiles = async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const existingFingerprints = new Set(
|
||||
@@ -1553,20 +1598,46 @@ function WorkbenchPage({
|
||||
window.requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
};
|
||||
|
||||
const handleReferenceUploadClick = () => {
|
||||
if (referenceItems.length > 0) {
|
||||
setToolbarMenuId(null);
|
||||
setReferencePreviewOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
referenceInputRef.current?.click();
|
||||
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
await processReferenceFiles(files);
|
||||
};
|
||||
|
||||
const handleReferenceAddMore = () => {
|
||||
setToolbarMenuId(null);
|
||||
setReferencePreviewOpen(true);
|
||||
referenceInputRef.current?.click();
|
||||
};
|
||||
const handleComposerDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
composerDragCounterRef.current += 1;
|
||||
if (composerDragCounterRef.current === 1) {
|
||||
setIsComposerDragging(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleComposerDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
composerDragCounterRef.current -= 1;
|
||||
if (composerDragCounterRef.current <= 0) {
|
||||
composerDragCounterRef.current = 0;
|
||||
setIsComposerDragging(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleComposerDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleComposerDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
composerDragCounterRef.current = 0;
|
||||
setIsComposerDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
void processReferenceFiles(files);
|
||||
}
|
||||
}, [activeMode]);
|
||||
|
||||
const insertPromptMention = (token: string) => {
|
||||
const rawBefore = inputValue.slice(0, cursorIndex);
|
||||
@@ -1946,6 +2017,7 @@ function WorkbenchPage({
|
||||
runKeepalivePoll(keepaliveTask);
|
||||
} else {
|
||||
let streamedText = "";
|
||||
let chatUsage: ChatMessage["taskUsage"] | undefined;
|
||||
setGenerationProgress(36);
|
||||
setGenerationStatus("正在回复");
|
||||
updateAssistantMessage(assistantMessageId, {
|
||||
@@ -1978,6 +2050,9 @@ function WorkbenchPage({
|
||||
});
|
||||
},
|
||||
abortController.signal,
|
||||
(usage) => {
|
||||
chatUsage = usage;
|
||||
},
|
||||
);
|
||||
|
||||
if (abortController.signal.aborted) return;
|
||||
@@ -1986,6 +2061,7 @@ function WorkbenchPage({
|
||||
const completedMessages = updateAssistantMessage(assistantMessageId, {
|
||||
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
|
||||
status: "completed",
|
||||
taskUsage: chatUsage,
|
||||
});
|
||||
if (!conversationId) {
|
||||
const conv = await conversationClient.create(
|
||||
@@ -2113,6 +2189,38 @@ function WorkbenchPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReleaseStuckTask = (message: ChatMessage) => {
|
||||
if (message.taskId) {
|
||||
taskAbortControllersRef.current.get(message.taskId)?.abort();
|
||||
taskAbortControllersRef.current.delete(message.taskId);
|
||||
removeKeepaliveTask(message.taskId);
|
||||
}
|
||||
if (message.conversationId) {
|
||||
void patchConversationMessage(message.conversationId, message.id, {
|
||||
body: buildLocalTimeoutMessage(message.mode === "image" ? "image" : "video"),
|
||||
status: "local_timeout",
|
||||
taskLifecycleStatus: "local_timeout",
|
||||
taskRefundStatus: message.taskRefundStatus || "unknown",
|
||||
taskStatusLabel: "本地占用已释放",
|
||||
});
|
||||
}
|
||||
setMessages((current) =>
|
||||
current.map((item) =>
|
||||
item.id === message.id
|
||||
? {
|
||||
...item,
|
||||
body: buildLocalTimeoutMessage(item.mode === "image" ? "image" : "video"),
|
||||
status: "local_timeout",
|
||||
taskLifecycleStatus: "local_timeout",
|
||||
taskRefundStatus: item.taskRefundStatus || "unknown",
|
||||
taskStatusLabel: "本地占用已释放",
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
syncActiveGenerationUi();
|
||||
};
|
||||
|
||||
const handleSuperResolveVideo = async (message: ChatMessage) => {
|
||||
if (!message.resultUrl || message.resultType !== "video") {
|
||||
setProjectError("仅支持对视频结果进行超分");
|
||||
@@ -2566,6 +2674,11 @@ function WorkbenchPage({
|
||||
>
|
||||
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
|
||||
</button>
|
||||
{(item.kind === "image" || item.kind === "video") && item.previewUrl ? (
|
||||
<span className="wb-composer__ref-zoom" aria-hidden="true">
|
||||
{item.kind === "video" ? <video src={item.previewUrl} muted playsInline /> : <img src={item.previewUrl} alt="" />}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="wb-composer__ref-remove"
|
||||
@@ -2617,7 +2730,7 @@ function WorkbenchPage({
|
||||
isOpen={toolbarMenuId === "image-model"}
|
||||
onToggle={() => toggleToolbarMenu("image-model")}
|
||||
onClose={closeToolbarMenus}
|
||||
onChange={setImageModel}
|
||||
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
<CompoundSelectChip
|
||||
@@ -2629,6 +2742,7 @@ function WorkbenchPage({
|
||||
onToggle={() => toggleToolbarMenu("image-settings")}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
{GRID_SUPPORTED_MODELS.has(imageModel) && (
|
||||
<SelectChip
|
||||
chipId="image-grid-mode"
|
||||
value={imageGridMode}
|
||||
@@ -2640,6 +2754,7 @@ function WorkbenchPage({
|
||||
onChange={setImageGridMode}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activeMode === "video" && (
|
||||
@@ -2823,7 +2938,14 @@ function WorkbenchPage({
|
||||
<h1 className="wb-home__title">今天想生成什么?</h1>
|
||||
</div>
|
||||
|
||||
<div className="wb-home__composer" ref={toolbarRef}>
|
||||
<div
|
||||
className={`wb-home__composer${isComposerDragging ? " wb-composer--drag-active" : ""}`}
|
||||
ref={toolbarRef}
|
||||
onDragEnter={handleComposerDragEnter}
|
||||
onDragLeave={handleComposerDragLeave}
|
||||
onDragOver={handleComposerDragOver}
|
||||
onDrop={handleComposerDrop}
|
||||
>
|
||||
<div className="wb-composer__content">
|
||||
<div className="wb-composer__input-row">
|
||||
{renderComposerReferences(false)}
|
||||
@@ -2959,7 +3081,7 @@ function WorkbenchPage({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.status === "failed" && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
|
||||
{(message.status === "failed" || message.status === "local_timeout") && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
|
||||
<div className="ai-chat-failed-actions">
|
||||
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
|
||||
<ReloadOutlined /> 重试
|
||||
@@ -2967,9 +3089,12 @@ function WorkbenchPage({
|
||||
<button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
|
||||
<AppstoreOutlined /> 切换模型
|
||||
</button>
|
||||
<button type="button" className="ai-chat-failed-actions__release" onClick={() => handleReleaseStuckTask(message)}>
|
||||
<StopOutlined /> 释放卡住任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message.status === "thinking" && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
|
||||
{(message.status === "thinking" || message.status === "stopping") && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
|
||||
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
|
||||
)}
|
||||
{message.status === "thinking" && message.mode === "chat" && (
|
||||
@@ -2977,6 +3102,11 @@ function WorkbenchPage({
|
||||
<span>{message.taskStatusLabel || generationStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
|
||||
<div className="ai-chat-task-billing-note">
|
||||
{formatTextTokenUsage(message.taskUsage)}
|
||||
</div>
|
||||
)}
|
||||
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
|
||||
<ResultCard
|
||||
message={message}
|
||||
@@ -2998,7 +3128,14 @@ function WorkbenchPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`wb-composer${composerHidden ? " is-hidden" : ""}`} ref={toolbarRef}>
|
||||
<section
|
||||
className={`wb-composer${composerHidden ? " is-hidden" : ""}${isComposerDragging ? " wb-composer--drag-active" : ""}`}
|
||||
ref={toolbarRef}
|
||||
onDragEnter={handleComposerDragEnter}
|
||||
onDragLeave={handleComposerDragLeave}
|
||||
onDragOver={handleComposerDragOver}
|
||||
onDrop={handleComposerDrop}
|
||||
>
|
||||
<div className="wb-composer__content">
|
||||
<div className="wb-composer__input-row">
|
||||
{renderComposerReferences(false)}
|
||||
|
||||
@@ -3,6 +3,24 @@ import type { ReactNode } from "react";
|
||||
import type { WorkbenchOption, WorkbenchFieldGroup } from "./workbenchConstants";
|
||||
import { getRatioOptionClassName, getSettingsGridColumnsClassName } from "./workbenchReferenceUtils";
|
||||
|
||||
const VIDEO_MODEL_ICON_URLS = {
|
||||
happyHorse: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/HappyHorse.svg",
|
||||
pixverse: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/Pixverse.svg",
|
||||
vidu: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/viduQ3.svg",
|
||||
wanxiang: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/wan.svg",
|
||||
kling: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/model-icons/kling.svg",
|
||||
} as const;
|
||||
|
||||
function getVideoModelIconUrl(option: WorkbenchOption): string | null {
|
||||
const text = `${option.value} ${option.label}`.toLowerCase();
|
||||
if (text.includes("happyhorse")) return VIDEO_MODEL_ICON_URLS.happyHorse;
|
||||
if (text.includes("pixverse")) return VIDEO_MODEL_ICON_URLS.pixverse;
|
||||
if (text.includes("vidu")) return VIDEO_MODEL_ICON_URLS.vidu;
|
||||
if (text.includes("wan") || text.includes("万相")) return VIDEO_MODEL_ICON_URLS.wanxiang;
|
||||
if (text.includes("kling") || text.includes("可灵")) return VIDEO_MODEL_ICON_URLS.kling;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SelectChip({
|
||||
chipId,
|
||||
value,
|
||||
@@ -56,6 +74,7 @@ export function SelectChip({
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const active = option.value === value;
|
||||
const iconUrl = chipId === "video-model" ? getVideoModelIconUrl(option) : null;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
@@ -71,6 +90,11 @@ export function SelectChip({
|
||||
>
|
||||
<span className="ai-workbench-select-chip__option-label">
|
||||
<span className="ai-workbench-select-chip__option-dot" aria-hidden="true" />
|
||||
{iconUrl ? (
|
||||
<span className="ai-workbench-select-chip__option-icon" aria-hidden="true">
|
||||
<img src={iconUrl} alt="" loading="lazy" />
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ai-workbench-select-chip__option-copy">
|
||||
<span className="ai-workbench-select-chip__option-title">
|
||||
<span>{option.label}</span>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
|
||||
|
||||
export type WorkbenchMode = "chat" | "image" | "video";
|
||||
|
||||
export interface WorkbenchChatAttachment {
|
||||
@@ -16,7 +18,10 @@ export interface WorkbenchChatMessage {
|
||||
body: string;
|
||||
prompt?: string;
|
||||
createdAt: string;
|
||||
status?: "thinking" | "queued" | "completed" | "failed";
|
||||
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
|
||||
taskLifecycleStatus?: GenerationLifecycleStatus;
|
||||
taskRefundStatus?: TaskRefundStatus;
|
||||
taskUsage?: TextTokenUsage;
|
||||
taskId?: string;
|
||||
conversationId?: number;
|
||||
taskProgress?: number;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isServerRequestError } from "../../api/serverConnection";
|
||||
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
|
||||
import type { WebGenerationPreviewTask } from "../../types";
|
||||
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type WorkbenchMode = "chat" | "image" | "video";
|
||||
@@ -71,7 +72,10 @@ export interface ChatMessage {
|
||||
body: string;
|
||||
prompt?: string;
|
||||
createdAt: string;
|
||||
status?: "thinking" | "queued" | "completed" | "failed";
|
||||
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
|
||||
taskLifecycleStatus?: GenerationLifecycleStatus;
|
||||
taskRefundStatus?: TaskRefundStatus;
|
||||
taskUsage?: TextTokenUsage;
|
||||
taskId?: string;
|
||||
conversationId?: number;
|
||||
taskProgress?: number;
|
||||
@@ -232,6 +236,13 @@ export const GRID_MODE_OPTIONS: WorkbenchOption[] = [
|
||||
{ value: "grid-25", label: "25 宫格" },
|
||||
];
|
||||
|
||||
export const GRID_SUPPORTED_MODELS = new Set([
|
||||
"wan2.7-image-pro",
|
||||
"wan2.7-image",
|
||||
"gpt-image-2",
|
||||
"gpt-image-2-vip",
|
||||
]);
|
||||
|
||||
export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [
|
||||
{ value: "omni", label: "全能参考" },
|
||||
{ value: "start-end", label: "首尾帧" },
|
||||
@@ -366,11 +377,16 @@ export function shouldPersistPatch(patch: Partial<ChatMessage>): boolean {
|
||||
return (
|
||||
patch.status === "completed" ||
|
||||
patch.status === "failed" ||
|
||||
patch.status === "local_timeout" ||
|
||||
patch.status === "stopping" ||
|
||||
typeof patch.taskId === "string" ||
|
||||
typeof patch.resultUrl === "string" ||
|
||||
typeof patch.resultOssKey === "string" ||
|
||||
typeof patch.resultOriginalUrl === "string" ||
|
||||
typeof patch.resultMimeType === "string"
|
||||
typeof patch.resultMimeType === "string" ||
|
||||
typeof patch.taskRefundStatus === "string" ||
|
||||
typeof patch.taskLifecycleStatus === "string" ||
|
||||
typeof patch.taskUsage === "object"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,18 +19,18 @@ export interface UseGenerationStatusReturn {
|
||||
export function useGenerationStatus(): UseGenerationStatusReturn {
|
||||
const [status, setStatus] = useState<GenStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef({ current: false });
|
||||
const abortRef = useRef(false);
|
||||
|
||||
const start = useCallback(() => {
|
||||
setStatus("generating");
|
||||
setError(null);
|
||||
abortRef.current = { current: false };
|
||||
abortRef.current = false;
|
||||
}, []);
|
||||
|
||||
const succeed = useCallback(() => setStatus("done"), []);
|
||||
const fail = useCallback((msg: string) => { setStatus("failed"); setError(msg); }, []);
|
||||
const reset = useCallback(() => { setStatus("idle"); setError(null); }, []);
|
||||
const cancel = useCallback(() => { abortRef.current.current = true; }, []);
|
||||
const cancel = useCallback(() => { abortRef.current = true; }, []);
|
||||
|
||||
return {
|
||||
status, error, abortRef, start, succeed, fail, reset, cancel,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
|
||||
import { aiGenerationClient } from "../api/aiGenerationClient";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
buildTaskFailureInfo,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../utils/taskLifecycle";
|
||||
|
||||
type PollCallback = (item: GenerationQueueItem) => void;
|
||||
|
||||
@@ -7,7 +13,7 @@ const activePollers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
const pollCallbacks = new Set<PollCallback>();
|
||||
|
||||
const POLL_INTERVAL = 3000;
|
||||
const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task
|
||||
const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
|
||||
|
||||
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
|
||||
pollCallbacks.add(callback);
|
||||
@@ -18,10 +24,25 @@ function notifyCallbacks(item: GenerationQueueItem): void {
|
||||
pollCallbacks.forEach((cb) => cb(item));
|
||||
}
|
||||
|
||||
function getQueueItemKind(item: GenerationQueueItem): "image" | "video" | "text" {
|
||||
if (item.type === "image") return "image";
|
||||
if (item.type === "video" || item.type === "ecommerce-video") return "video";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function getQueueItemModel(item: GenerationQueueItem): string | undefined {
|
||||
return typeof item.params?.model === "string" ? item.params.model : undefined;
|
||||
}
|
||||
|
||||
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
|
||||
const key = `poll-${item.id}`;
|
||||
if (activePollers.has(key)) return;
|
||||
|
||||
const kind = getQueueItemKind(item);
|
||||
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
|
||||
let lastProgress = Math.max(0, Number(item.progress || 0));
|
||||
let lastProgressAt = Date.now();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
|
||||
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
|
||||
@@ -30,18 +51,31 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
|
||||
}
|
||||
|
||||
attemptsRef.current++;
|
||||
if (attemptsRef.current > MAX_POLL_ATTEMPTS) {
|
||||
const timeoutReason = isTaskLocallyTimedOut({
|
||||
startedAt: current.createdAt || item.createdAt || Date.now(),
|
||||
lastProgressAt,
|
||||
progress: lastProgress,
|
||||
policy: timeoutPolicy,
|
||||
});
|
||||
if (timeoutReason || attemptsRef.current > MAX_POLL_ATTEMPTS) {
|
||||
const error = buildLocalTimeoutMessage(kind);
|
||||
useGenerationStore.getState().updateTask(item.id, {
|
||||
status: "failed",
|
||||
error: "任务超时,请重新提交",
|
||||
error,
|
||||
});
|
||||
notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" });
|
||||
notifyCallbacks({ ...item, status: "failed", error });
|
||||
cleanupPoll(key);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
|
||||
const nextProgress = Number(status.progress || 0);
|
||||
if (nextProgress > lastProgress || status.status === "completed") {
|
||||
lastProgress = Math.max(lastProgress, nextProgress);
|
||||
lastProgressAt = Date.now();
|
||||
}
|
||||
|
||||
const patch: Partial<GenerationQueueItem> = {
|
||||
progress: status.progress,
|
||||
resultUrl: status.resultUrl || current.resultUrl,
|
||||
@@ -55,6 +89,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
|
||||
cleanupPoll(key);
|
||||
} else if (status.status === "failed" || status.status === "cancelled") {
|
||||
patch.status = "failed";
|
||||
patch.error = buildTaskFailureInfo(status.error).message;
|
||||
useGenerationStore.getState().updateTask(item.id, patch);
|
||||
notifyCallbacks({ ...item, ...patch, status: "failed" });
|
||||
cleanupPoll(key);
|
||||
@@ -64,7 +99,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
|
||||
notifyCallbacks({ ...item, ...patch, status: "running" });
|
||||
}
|
||||
} catch {
|
||||
// Network error during poll — keep trying
|
||||
// Network errors during polling are retried until the lifecycle guard trips.
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
@@ -105,24 +140,20 @@ export function stopAllPolling(): void {
|
||||
activePollers.clear();
|
||||
}
|
||||
|
||||
// ── Recovery on page load ──────────────────────────
|
||||
export function recoverAndResumeTasks(): void {
|
||||
const pendingTasks = useGenerationStore.getState().getRunningTasks();
|
||||
if (!pendingTasks.length) return;
|
||||
|
||||
pendingTasks.forEach((task) => {
|
||||
if (task.taskId) {
|
||||
// Mark as pending so the workbench/ecommerce can re-submit to polling
|
||||
useGenerationStore.getState().updateTask(task.id, { status: "pending" });
|
||||
} else {
|
||||
// No taskId means it was queued but never submitted — mark failed
|
||||
useGenerationStore.getState().updateTask(task.id, {
|
||||
status: "failed",
|
||||
error: "页面刷新后任务丢失,请重新提交",
|
||||
error: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交。",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start polling recovered tasks
|
||||
setTimeout(() => startBackgroundPolling(), 500);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
@import "./pages/studio-layout.css";
|
||||
@import "./pages/image-workbench.css";
|
||||
@import "./pages/subtitle-removal.css";
|
||||
@import "./pages/dialog-generator.css";
|
||||
@import "./pages/size-template.css";
|
||||
@import "./pages/script-tokens-v5.css";
|
||||
@import "./pages/script-tokens.css";
|
||||
|
||||
@@ -1 +1,13 @@
|
||||
/* Agent page rules move here as they are retired from legacy-pages.css. */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.agent-page {
|
||||
padding: 16px 16px 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.agent-page {
|
||||
padding: 12px 10px 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,3 +243,24 @@
|
||||
max-height: calc(100vh - 190px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.asset-preview-modal {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.asset-preview-modal__panel {
|
||||
width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.asset-preview-modal__body {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.asset-preview-modal__body img,
|
||||
.asset-preview-modal__body video {
|
||||
max-height: calc(100vh - 160px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4211,3 +4211,30 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.avatar-console-page {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.avatar-console-main {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.avatar-console-scroll {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.avatar-console-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.avatar-console-video-actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,3 +722,282 @@
|
||||
right: -9999px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
/* Tool Modal Overlay */
|
||||
.studio-canvas-tool-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.studio-canvas-tool-modal {
|
||||
position: relative;
|
||||
width: 90vw;
|
||||
max-width: 720px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
border-radius: 16px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-subtle);
|
||||
box-shadow: var(--shadow-heavy, 0 12px 40px rgba(0,0,0,0.4));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-modal__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-default);
|
||||
}
|
||||
|
||||
.studio-canvas-tool-modal__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--bg-subtle);
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-modal__close:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Tool Panel Components */
|
||||
.studio-canvas-tool-panel {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel--inpaint {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__preview {
|
||||
flex: 0 0 260px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__controls {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__options button {
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--fg-default);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__options button.is-active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--fg-default);
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__submit {
|
||||
margin-top: auto;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__reset {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--fg-default);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__canvas-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__canvas-bg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.studio-canvas-tool-panel__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
Responsive: Canvas
|
||||
Breakpoints: 900px / 560px
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.studio-canvas-page {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.studio-canvas-toolbar {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-weak);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.studio-canvas-toolbar__group {
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.studio-canvas-toolbar button {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.studio-canvas-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.studio-canvas-node {
|
||||
min-width: 140px;
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.studio-canvas-page {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.studio-canvas-toolbar {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 8px;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border-weak);
|
||||
}
|
||||
|
||||
.studio-canvas-toolbar button {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.studio-canvas-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.studio-canvas-node {
|
||||
min-width: 110px;
|
||||
min-height: 48px;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.studio-canvas-project-bar {
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
bottom: 80px;
|
||||
gap: 4px;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.studio-canvas-project-bar__name-form {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,3 +117,52 @@
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
Responsive: Community
|
||||
Breakpoints: 900px / 560px
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.community-page {
|
||||
padding: 12px 16px 80px;
|
||||
}
|
||||
|
||||
.community-carousel__slide--video {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.community-carousel__video {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.community-case-card__preview {
|
||||
min-height: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.community-page {
|
||||
padding: 10px 10px 80px;
|
||||
}
|
||||
|
||||
.community-carousel__slide--video {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.community-carousel__video {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.community-case-card__preview {
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
.community-case-empty {
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.community-case-empty__icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
.dialog-generator-page {
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0, 255, 136, 0.04) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 60% 50% at 80% 70%, rgba(42, 159, 212, 0.03) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #070b10 0%, #05080d 100%);
|
||||
color: #e8eaef;
|
||||
}
|
||||
|
||||
.dialog-generator-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 0.42fr) minmax(0, 0.58fr);
|
||||
gap: clamp(18px, 2.8vw, 34px);
|
||||
min-height: var(--shell-content-height, 100vh);
|
||||
padding: clamp(24px, 4vw, 52px);
|
||||
}
|
||||
|
||||
.dialog-generator-panel,
|
||||
.dialog-generator-preview-card {
|
||||
border: 1px solid rgba(0, 255, 136, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
box-shadow:
|
||||
0 24px 72px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.dialog-generator-panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 24px;
|
||||
padding: clamp(22px, 2.6vw, 34px);
|
||||
}
|
||||
|
||||
.dialog-generator-heading {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-generator-kicker {
|
||||
color: #00ff88;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dialog-generator-heading h1 {
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #00ff88, #22f0c0, #4fc3f7);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-size: clamp(32px, 3.6vw, 56px);
|
||||
font-weight: 950;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.dialog-generator-heading p,
|
||||
.dialog-generator-hint,
|
||||
.dialog-generator-preview-head p {
|
||||
margin: 0;
|
||||
color: #9aa1b8;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dialog-generator-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-generator-section h2 {
|
||||
margin: 0;
|
||||
color: #f6f8fb;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.dialog-generator-drop {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
min-height: 168px;
|
||||
border: 1px dashed rgba(0, 255, 136, 0.28);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 255, 136, 0.035);
|
||||
color: #e8eaef;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-drop:hover {
|
||||
border-color: rgba(0, 255, 136, 0.5);
|
||||
background: rgba(0, 255, 136, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dialog-generator-drop-icon {
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
.dialog-generator-drop strong {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.dialog-generator-drop small,
|
||||
.dialog-generator-style small {
|
||||
color: #62697f;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-generator-style-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dialog-generator-color-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-generator-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #dce3ed;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-color:hover,
|
||||
.dialog-generator-color.is-active {
|
||||
border-color: var(--text-color);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dialog-generator-color span {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.38);
|
||||
border-radius: 50%;
|
||||
background: var(--text-color);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--text-color) 42%, transparent);
|
||||
}
|
||||
|
||||
.dialog-generator-color strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dialog-generator-style {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #e8eaef;
|
||||
padding: 15px 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-style:hover {
|
||||
border-color: rgba(0, 255, 136, 0.28);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.dialog-generator-style span:last-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-generator-style strong {
|
||||
color: #f7fafc;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch.is-white {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch.is-blue {
|
||||
background: #165dff;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch.is-amber {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.dialog-generator-swatch.is-gray {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.dialog-generator-clear {
|
||||
min-height: 48px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e8eaef;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
background 180ms ease;
|
||||
}
|
||||
|
||||
.dialog-generator-clear:hover {
|
||||
border-color: rgba(255, 77, 103, 0.32);
|
||||
background: rgba(255, 77, 103, 0.1);
|
||||
}
|
||||
|
||||
.dialog-generator-preview-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: clamp(22px, 2.6vw, 34px);
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head span {
|
||||
color: #00ff88;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head h2 {
|
||||
margin: 4px 0 0;
|
||||
color: #ffffff;
|
||||
font-size: clamp(24px, 2vw, 34px);
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head p {
|
||||
max-width: 440px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dialog-generator-preview {
|
||||
position: relative;
|
||||
min-height: 520px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||
rgba(5, 8, 13, 0.72);
|
||||
background-size: 32px 32px, 32px 32px, auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dialog-generator-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.dialog-generator-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 12px;
|
||||
color: #62697f;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dialog-generator-empty span {
|
||||
font-size: 52px;
|
||||
}
|
||||
|
||||
.dialog-generator-empty p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
min-width: 140px;
|
||||
max-width: 280px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
user-select: none;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed {
|
||||
min-width: 0;
|
||||
max-width: min(420px, 80%);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble:hover {
|
||||
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-dragging {
|
||||
z-index: 20;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.is-dragging {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style1 {
|
||||
border: 2px solid #cbd5e1;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style2 {
|
||||
border: 2px solid #4f8aff;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
background: rgba(22, 93, 255, 0.95);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style3 {
|
||||
border: 2px solid #f59e0b;
|
||||
background: rgba(255, 247, 237, 0.97);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style4 {
|
||||
border: 2px solid #6b7280;
|
||||
border-radius: 4px;
|
||||
background: rgba(248, 250, 252, 0.97);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.style1,
|
||||
.dialog-generator-bubble.is-confirmed.style2,
|
||||
.dialog-generator-bubble.is-confirmed.style3,
|
||||
.dialog-generator-bubble.is-confirmed.style4 {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog-generator-delete {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble:hover .dialog-generator-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dialog-generator-text,
|
||||
.dialog-generator-text-display {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dialog-text-color, #1e293b);
|
||||
padding: 0;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dialog-generator-text-display {
|
||||
width: max-content;
|
||||
max-width: min(420px, 80vw);
|
||||
color: var(--dialog-text-color, #ffffff);
|
||||
font-size: clamp(18px, 2.2vw, 30px);
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.72),
|
||||
0 0 1px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.dialog-generator-text::placeholder {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style2 .dialog-generator-text,
|
||||
.dialog-generator-bubble.style2 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #fff);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.style2 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #7fb4ff);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style2 .dialog-generator-text::placeholder {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style3 .dialog-generator-text,
|
||||
.dialog-generator-bubble.style3 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #92400e);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.style3 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #ffd76a);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style3 .dialog-generator-text::placeholder {
|
||||
color: rgba(146, 64, 14, 0.4);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style4 .dialog-generator-text,
|
||||
.dialog-generator-bubble.style4 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #1f2937);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed.style4 .dialog-generator-text-display {
|
||||
color: var(--dialog-text-color, #111827);
|
||||
text-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.72),
|
||||
0 0 8px rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.dialog-generator-confirm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #165dff;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
transition:
|
||||
filter 0.15s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.dialog-generator-confirm:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style2 .dialog-generator-confirm {
|
||||
background: #fff;
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style3 .dialog-generator-confirm {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.style4 .dialog-generator-confirm {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.dialog-generator-edit-hint {
|
||||
display: none;
|
||||
color: rgba(0, 0, 0, 0.36);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed .dialog-generator-confirm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dialog-generator-bubble.is-confirmed .dialog-generator-edit-hint {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.dialog-generator-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-generator-preview-head p {
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.dialog-generator-shell {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.dialog-generator-preview {
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
@@ -179,9 +179,9 @@
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #101318;
|
||||
padding: 26px;
|
||||
padding: 32px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -735,10 +735,10 @@
|
||||
|
||||
.ecom-video-tree {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ── Source node ── */
|
||||
@@ -746,9 +746,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ecom-video-tree-node {
|
||||
@@ -762,8 +762,8 @@
|
||||
}
|
||||
|
||||
.ecom-video-tree-node--source {
|
||||
width: 150px;
|
||||
height: 190px;
|
||||
width: 180px;
|
||||
height: 230px;
|
||||
flex-shrink: 0;
|
||||
border-color: #1c4d3a;
|
||||
background: #162820;
|
||||
@@ -785,9 +785,9 @@
|
||||
|
||||
/* ── Text node (分镜文本) ── */
|
||||
.ecom-video-tree-node--text {
|
||||
min-width: 120px;
|
||||
max-width: 150px;
|
||||
padding: 14px 12px;
|
||||
min-width: 140px;
|
||||
max-width: 170px;
|
||||
padding: 16px 14px;
|
||||
cursor: default;
|
||||
border-color: #2a3d30;
|
||||
background: #131d1a;
|
||||
@@ -824,8 +824,8 @@
|
||||
/* ── Image node (分镜图) ── */
|
||||
.ecom-video-tree-node--image,
|
||||
.ecom-video-tree-node--video {
|
||||
width: 150px;
|
||||
height: 120px;
|
||||
width: 170px;
|
||||
height: 136px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -919,7 +919,7 @@
|
||||
/* ── Trunk connector (分支连接线) ── */
|
||||
.ecom-video-tree__trunk {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
width: 56px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
@@ -928,7 +928,7 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 24px;
|
||||
width: 28px;
|
||||
height: 2px;
|
||||
background: #3a4550;
|
||||
transform: translateY(-50%);
|
||||
@@ -948,10 +948,15 @@
|
||||
|
||||
.ecom-video-tree__branches-line {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
left: 28px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 24px;
|
||||
width: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ecom-video-tree__branches-line::before {
|
||||
@@ -965,24 +970,28 @@
|
||||
}
|
||||
|
||||
.ecom-video-tree__branch-tap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ecom-video-tree__branch-tap::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #3a4550;
|
||||
}
|
||||
|
||||
.ecom-video-tree__branch-tap:nth-child(1) { top: 0; }
|
||||
.ecom-video-tree__branch-tap:nth-child(2) { top: 50%; transform: translateY(-50%); }
|
||||
.ecom-video-tree__branch-tap:nth-child(3) { bottom: 0; }
|
||||
|
||||
.ecom-video-tree__branch-tap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #00ff88, transparent);
|
||||
animation: ecom-tree-branch-flow 2.4s ease-in-out infinite;
|
||||
}
|
||||
@@ -1016,18 +1025,19 @@
|
||||
.ecom-video-tree__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
padding: 8px 0;
|
||||
align-self: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ecom-video-tree__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
flex: 1;
|
||||
gap: 12px;
|
||||
animation: ecom-tree-row-in 480ms var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1)) both;
|
||||
}
|
||||
|
||||
@@ -1091,3 +1101,313 @@
|
||||
70% { opacity: 0.5; }
|
||||
100% { opacity: 0; transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* ── Preview lightbox overlay ────────────────────── */
|
||||
.ecom-video-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
cursor: zoom-out;
|
||||
animation: ecom-preview-fade-in 200ms ease;
|
||||
}
|
||||
|
||||
.ecom-video-preview-overlay__close {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ecom-video-preview-overlay img,
|
||||
.ecom-video-preview-overlay video {
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@keyframes ecom-preview-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── History panel ──────────────────────────────── */
|
||||
|
||||
.ecom-video-history-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
height: 100vh;
|
||||
background: #1a1d24;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5);
|
||||
animation: ecom-history-slide-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes ecom-history-slide-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__close {
|
||||
margin-left: auto;
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 60px 20px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ecom-video-history-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__title {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__date {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__delete {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__delete:hover {
|
||||
background: rgba(255, 80, 80, 0.15);
|
||||
color: #ff5050;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__scenes {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__scene {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ecom-video-history-card__scene img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__scene img:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__video-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.ecom-video-history-card__video-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__pager button {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__pager button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ecom-video-history-panel__pager button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Delete confirmation dialog ─────────────────── */
|
||||
|
||||
.ecom-video-confirm-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 12px;
|
||||
background: #1e2128;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
max-width: 340px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__icon {
|
||||
font-size: 36px;
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions button {
|
||||
padding: 6px 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions button.is-danger {
|
||||
background: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ecom-video-confirm-dialog__actions button.is-danger:hover {
|
||||
background: #ff7875;
|
||||
border-color: #ff7875;
|
||||
}
|
||||
|
||||
@@ -596,14 +596,27 @@ textarea.image-workbench-prompt {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--fg-dim);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--fg-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.image-workbench-empty .anticon {
|
||||
font-size: 32px;
|
||||
opacity: 0.5;
|
||||
font-size: 40px;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.image-workbench-empty strong {
|
||||
font-size: 18px;
|
||||
color: var(--fg-body, #eee);
|
||||
}
|
||||
|
||||
.image-workbench-empty span {
|
||||
max-width: 320px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.image-workbench-empty--button {
|
||||
@@ -824,22 +837,24 @@ textarea.image-workbench-prompt {
|
||||
|
||||
.image-workbench-panel--right .image-workbench-result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.image-workbench-result-thumb {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
|
||||
aspect-ratio: 1;
|
||||
transition: border-color 0.15s;
|
||||
transition: border-color 200ms ease, transform 200ms ease, box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.image-workbench-result-thumb:hover {
|
||||
border-color: var(--accent, #2dd4bf);
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.image-workbench-result-thumb img {
|
||||
@@ -1598,30 +1613,30 @@ textarea.image-workbench-prompt {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
|
||||
.image-workbench-generating strong {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
color: var(--fg-default);
|
||||
}
|
||||
|
||||
.image-workbench-progress-bar {
|
||||
width: 320px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
width: min(420px, 80%);
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: var(--bg-inset);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-workbench-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 70%, white));
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.image-workbench-cancel {
|
||||
@@ -1642,30 +1657,30 @@ textarea.image-workbench-prompt {
|
||||
}
|
||||
|
||||
.image-workbench-result-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.image-workbench-result-item {
|
||||
display: block;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--radius-md, 12px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-weak);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.image-workbench-result-item:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(var(--accent-rgb, 45, 212, 191), 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.image-workbench-result-item img {
|
||||
@@ -1674,20 +1689,26 @@ textarea.image-workbench-prompt {
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: var(--bg-inset);
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
.image-workbench-result-item:hover img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.image-workbench-result-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
width: min(100%, 500px);
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.image-workbench-result-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.image-workbench-result-actions button {
|
||||
@@ -1735,3 +1756,48 @@ textarea.image-workbench-prompt {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Result card entrance animation */
|
||||
@keyframes image-workbench-result-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.image-workbench-result-card {
|
||||
animation: image-workbench-result-enter 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.image-workbench-result-card:nth-child(2) { animation-delay: 0.08s; }
|
||||
.image-workbench-result-card:nth-child(3) { animation-delay: 0.16s; }
|
||||
.image-workbench-result-card:nth-child(4) { animation-delay: 0.24s; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.image-workbench-page {
|
||||
padding: 12px;
|
||||
}
|
||||
.image-workbench-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.image-workbench-page {
|
||||
padding: 8px 8px 80px;
|
||||
}
|
||||
.image-workbench-toolbar {
|
||||
gap: 4px;
|
||||
}
|
||||
.image-workbench-toolbar button {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1286,3 +1286,23 @@ textarea.image-workbench-prompt,
|
||||
justify-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.token-usage-page {
|
||||
padding: 0 8px 80px;
|
||||
}
|
||||
|
||||
.token-usage-page .script-token-page__scroll,
|
||||
.script-token-page__scroll {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.token-member-card {
|
||||
padding: 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-member-card__head {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,3 +313,39 @@
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.more-page-v2 {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.more-page-v2__header {
|
||||
padding: 10px 12px 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.more-page-v2__scroll {
|
||||
padding: 12px 10px 72px;
|
||||
}
|
||||
|
||||
.more-page-v2__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.more-page-v2__featured-grid {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.more-card--featured {
|
||||
padding: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.more-card__featured-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,32 @@
|
||||
|
||||
/* ── 代表作滚动容器:固定3列,刚好显示9个(3行),超出可滚动,隐藏滚动条 ── */
|
||||
.profile-page__works-scroll {
|
||||
max-height: 390px; /* 3行卡片:3 × 120(min-height) + 2 × 10(gap) = 380px,留10px余量 */
|
||||
max-height: 390px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.profile-page__works-scroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari/Edge */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.profile-page__works-scroll .profile-page__list-grid {
|
||||
grid-template-columns: repeat(3, 1fr); /* 固定3列,刚好3×3=9个可见 */
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.profile-page,
|
||||
.auth-page,
|
||||
.settings-page {
|
||||
padding: 16px 16px 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.profile-page,
|
||||
.auth-page,
|
||||
.settings-page {
|
||||
padding: 12px 10px 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,3 +164,40 @@
|
||||
.provider-health-table tr:hover td {
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.provider-health-page__inner {
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.provider-health-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.provider-health-page__inner {
|
||||
padding: 12px 12px 80px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-health-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.provider-health-toolbar {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-health-table {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.provider-health-table th,
|
||||
.provider-health-table td {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
@@ -474,3 +474,24 @@
|
||||
width: min(100%, 430px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.size-template-page {
|
||||
padding: 12px 10px 80px;
|
||||
}
|
||||
|
||||
.size-template-main-frame {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.size-template-spec-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.size-template-preview-note,
|
||||
.size-template-detail-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,3 +674,19 @@
|
||||
max-height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.studio-tool-layout {
|
||||
grid-template-rows: 40px auto minmax(200px, 1fr) 32px;
|
||||
}
|
||||
|
||||
.studio-tool-layout__toolstrip {
|
||||
padding: 6px 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.studio-panel {
|
||||
max-height: 240px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,3 +133,15 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.subtitle-removal-page {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.subtitle-removal-page {
|
||||
padding: 8px 8px 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +202,9 @@
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
min-height: clamp(360px, 40vw, 520px);
|
||||
min-height: clamp(560px, 52vw, 760px);
|
||||
}
|
||||
|
||||
/* ===== Tool Cards ===== */
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
|
||||
.wb-prompt-cases__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
grid-auto-flow: dense;
|
||||
grid-auto-rows: 10px;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-prompt-case-card {
|
||||
@@ -34,22 +34,22 @@
|
||||
|
||||
.wb-prompt-case-card--ratio-wide {
|
||||
grid-column: span 1;
|
||||
grid-row: span 8;
|
||||
grid-row: span 13;
|
||||
}
|
||||
|
||||
.wb-prompt-case-card--ratio-tall {
|
||||
grid-column: span 1;
|
||||
grid-row: span 23;
|
||||
grid-row: span 30;
|
||||
}
|
||||
|
||||
.wb-prompt-case-card--ratio-square {
|
||||
grid-column: span 1;
|
||||
grid-row: span 13;
|
||||
grid-row: span 18;
|
||||
}
|
||||
|
||||
.wb-prompt-case-card--ratio-portrait {
|
||||
grid-column: span 1;
|
||||
grid-row: span 16;
|
||||
grid-row: span 24;
|
||||
}
|
||||
|
||||
.wb-prompt-case-card img {
|
||||
@@ -328,7 +328,7 @@
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.wb-prompt-cases__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-auto-rows: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -387,7 +387,7 @@
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.wb-prompt-cases__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -440,3 +440,137 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
Responsive: Workbench Launch & Active State
|
||||
Breakpoints: 900px / 560px
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 900px: Tablet — launch state ── */
|
||||
@media (max-width: 900px) {
|
||||
.wb-home {
|
||||
padding: 36px 16px 80px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.wb-home__title {
|
||||
font-size: clamp(24px, 6vw, 36px);
|
||||
}
|
||||
|
||||
.wb-home__composer {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wb-home__suggestions {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wb-showcase {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wb-showcase__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Active state: message thread */
|
||||
.ai-workbench-content-scroll {
|
||||
width: 100%;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.ai-chat-message-stack {
|
||||
max-width: 86%;
|
||||
}
|
||||
|
||||
.ai-chat-message-bubble {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Composer in active state */
|
||||
.wb-composer {
|
||||
width: 100%;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 560px: Phone — full-width compact ── */
|
||||
@media (max-width: 560px) {
|
||||
.wb-home {
|
||||
padding: 24px 10px 72px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.wb-home__title {
|
||||
font-size: clamp(20px, 7vw, 28px);
|
||||
}
|
||||
|
||||
.wb-home__glow {
|
||||
width: 240px;
|
||||
height: 120px;
|
||||
top: -40px;
|
||||
}
|
||||
|
||||
.wb-showcase__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-suggestion-chip {
|
||||
font-size: 12px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
/* Active state: messages fill screen */
|
||||
.ai-workbench-content-scroll {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.ai-workbench-thread-shell {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ai-chat-message-list {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-message-stack {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.ai-chat-message-bubble {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.ai-chat-message-bubble--user {
|
||||
border-radius: 12px 12px 4px 12px;
|
||||
}
|
||||
|
||||
.ai-chat-message-bubble--ai {
|
||||
border-radius: 12px 12px 12px 4px;
|
||||
}
|
||||
|
||||
.ai-chat-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 26px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.ai-chat-message-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Composer at bottom */
|
||||
.wb-composer {
|
||||
padding: 6px 0 10px;
|
||||
}
|
||||
|
||||
.wb-chat-scroll-actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,12 +559,20 @@
|
||||
}
|
||||
|
||||
.web-shell__page {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-color: rgba(var(--accent-rgb), 0.42) transparent;
|
||||
}
|
||||
|
||||
.keepalive-ecommerce {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Info button & popover ────────────────────── */
|
||||
.info-button {
|
||||
display: inline-grid;
|
||||
@@ -888,6 +896,21 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.brand-lockup__tone {
|
||||
display: none;
|
||||
}
|
||||
.creator-button {
|
||||
display: none;
|
||||
}
|
||||
.member-button {
|
||||
max-width: 148px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.web-shell {
|
||||
overflow: hidden;
|
||||
@@ -954,6 +977,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 640px: Narrower topbar adjustments ── */
|
||||
@media (max-width: 640px) {
|
||||
.brand-lockup__name {
|
||||
font-size: 14px;
|
||||
@@ -973,3 +997,33 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 560px: Phone-sized compact layout ── */
|
||||
@media (max-width: 560px) {
|
||||
.web-topbar {
|
||||
flex: 0 0 44px;
|
||||
padding: 6px 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.brand-lockup__mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.brand-lockup__name {
|
||||
font-size: 13px;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.profile-button,
|
||||
.icon-button {
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,3 +84,14 @@
|
||||
--danger: var(--error);
|
||||
--shadow-soft: var(--shadow-panel);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
Responsive Breakpoints (standardized)
|
||||
Reference-only — CSS custom properties cannot be used
|
||||
inside @media queries, but all pages should align to
|
||||
these three breakpoints for consistency.
|
||||
|
||||
Phone : 560px (max-width — portrait / landscape)
|
||||
Tablet : 900px (max-width — tablet / small desktop)
|
||||
Desktop: 1180px (max-width — large desktop)
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -22,6 +22,7 @@ export type WebViewKey =
|
||||
| "more"
|
||||
| "watermarkRemoval"
|
||||
| "subtitleRemoval"
|
||||
| "dialogGenerator"
|
||||
| "communityReview"
|
||||
| "communityCaseAdd"
|
||||
| "report"
|
||||
|
||||