Feat/dialog generator cancel generation #14

Merged
stringadmin merged 4 commits from feat/dialog-generator-cancel-generation into master 2026-06-04 17:01:10 +00:00
12 changed files with 3452 additions and 204 deletions
Showing only changes of commit 90e3b90e34 - Show all commits
+72
View File
@@ -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.");
+2 -2
View File
@@ -913,7 +913,7 @@ export const keyServerClient = {
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> { async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
const stored = readStoredSession(); const stored = readStoredSession();
if (!stored) { if (!stored) {
throw new Error("闇€瑕佸厛鐧诲綍"); throw new Error("需要先登录");
} }
const safeProjectId = encodeURIComponent(projectId.trim()); const safeProjectId = encodeURIComponent(projectId.trim());
@@ -1000,7 +1000,7 @@ export const keyServerClient = {
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> { async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
const stored = readStoredSession(); const stored = readStoredSession();
if (!stored) { if (!stored) {
throw new Error("闇€瑕佸厛鐧诲綍"); throw new Error("需要先登录");
} }
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`; const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
+1 -1
View File
@@ -71,7 +71,7 @@ export const modelCapabilitiesClient = {
let payload: unknown; let payload: unknown;
try { try {
payload = await serverRequest<unknown>(`config/profile?name=${encodeURIComponent(name)}`); payload = await serverRequest<unknown>(`public/config/profile?name=${encodeURIComponent(name)}`);
} catch (error) { } catch (error) {
if (isOptionalApiRouteMissing(error)) { if (isOptionalApiRouteMissing(error)) {
modelCapabilitiesRouteMissing = true; modelCapabilitiesRouteMissing = true;
+3 -5
View File
@@ -26,7 +26,6 @@
VideoCameraOutlined, VideoCameraOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { import {
Background,
ReactFlow, ReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
@@ -3560,7 +3559,8 @@ function CanvasPage({
onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove} onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel} onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
style={{ 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-x": `${canvasViewport.x}px`,
"--canvas-bg-y": `${canvasViewport.y}px`, "--canvas-bg-y": `${canvasViewport.y}px`,
cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined, cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined,
@@ -3748,9 +3748,7 @@ function CanvasPage({
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick} onPaneClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneClick}
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu} 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()}> <div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button> <button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}> <button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
+170 -9
View File
@@ -19,6 +19,102 @@ import type {
PlanStep, PlanStep,
} from "./ecommerceVideoTypes"; } 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 { export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void; onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void;
@@ -305,6 +401,15 @@ export interface VideoHistoryScene {
videoUrl?: 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 { export interface VideoHistoryItem {
id: number; id: number;
title: string; title: string;
@@ -330,22 +435,74 @@ function getAuthHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
export async function saveVideoHistory(payload: { export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
title: string; const uploadAssetByUrl = payload.uploadAssetByUrl;
config: Record<string, unknown>; const scenes = await Promise.all(
plan: Record<string, unknown>; payload.scenes.map(async (scene) => {
scenes: VideoHistoryScene[]; const [image, video] = await Promise.all([
sourceImageUrls: string[]; resolveDurableMediaUrl(scene.imageUrl, {
}): Promise<{ id: number; createdAt: string }> { 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, { const res = await fetch(API_BASE, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeaders() }, headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(payload), body: JSON.stringify(historyPayload),
}); });
if (!res.ok) throw new Error("Failed to save video history"); if (!res.ok) throw new Error("Failed to save video history");
return res.json(); 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( export async function fetchVideoHistory(
limit = 20, limit = 20,
offset = 0, offset = 0,
@@ -355,7 +512,11 @@ export async function fetchVideoHistory(
{ headers: getAuthHeaders() }, { headers: getAuthHeaders() },
); );
if (!res.ok) throw new Error("Failed to fetch video history"); if (!res.ok) throw new Error("Failed to fetch video history");
return res.json(); const history = (await res.json()) as VideoHistoryListResponse;
return {
...history,
items: history.items.map(removeTemporaryHistoryUrls),
};
} }
export async function deleteVideoHistory(id: number): Promise<void> { export async function deleteVideoHistory(id: number): Promise<void> {
+100 -53
View File
@@ -5,10 +5,13 @@ import {
CloseOutlined, CloseOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FileImageOutlined,
FolderOpenOutlined,
LockOutlined, LockOutlined,
MailOutlined, MailOutlined,
MobileOutlined, MobileOutlined,
PhoneOutlined, PhoneOutlined,
PlayCircleOutlined,
PlusOutlined, PlusOutlined,
SafetyOutlined, SafetyOutlined,
ShareAltOutlined, ShareAltOutlined,
@@ -180,6 +183,19 @@ function formatAssetStatus(status: string | undefined): string {
return status || "资产"; 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({ function ProfilePage({
session, session,
usage, usage,
@@ -608,22 +624,50 @@ function ProfilePage({
</div> </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 = () => { const renderActivePanel = () => {
if (activePanel === "works") { if (activePanel === "works") {
return visibleWorks.length ? ( return visibleWorks.length ? (
<div className="profile-page__works-scroll"> <div className="profile-page__works-scroll">
<div className="profile-page__list-grid motion-stagger"> <div className="profile-page__list-grid motion-stagger">
{visibleWorks.map((task) => ( {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"> <div className="profile-page__list-card-head">
<strong>{task.title}</strong> <strong>{task.title}</strong>
<span>{formatTaskType(task.type)}</span>
</div> </div>
<p>{task.prompt}</p> <p>{task.prompt}</p>
<div className="profile-page__list-card-meta"> <div className="profile-page__list-card-meta">
<span>{formatTaskStatus(task.status)}</span> <span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span> <span>{formatProfileDate(task.createdAt)}</span>
</div> </div>
</div>
</article> </article>
))} ))}
</div> </div>
@@ -637,10 +681,11 @@ function ProfilePage({
return projects.length ? ( return projects.length ? (
<div className="profile-page__list-grid motion-stagger"> <div className="profile-page__list-grid motion-stagger">
{projects.map((project) => ( {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"> <div className="profile-page__list-card-head">
<strong>{project.name}</strong> <strong>{project.name}</strong>
<span>{formatProfileDate(project.updatedAt)}</span>
{onDeleteProject ? ( {onDeleteProject ? (
<button <button
type="button" type="button"
@@ -655,7 +700,8 @@ function ProfilePage({
<p>{project.description || "最近更新的项目"}</p> <p>{project.description || "最近更新的项目"}</p>
<div className="profile-page__list-card-meta"> <div className="profile-page__list-card-meta">
<span>{project.storyboardCount} </span> <span>{project.storyboardCount} </span>
<span>{project.imageCount} / {project.videoCount} </span> <span>{formatProfileDate(project.updatedAt)}</span>
</div>
</div> </div>
</article> </article>
))} ))}
@@ -669,16 +715,19 @@ function ProfilePage({
return savedAssets.length ? ( return savedAssets.length ? (
<div className="profile-page__list-grid"> <div className="profile-page__list-grid">
{savedAssets.map((asset) => ( {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"> <div className="profile-page__list-card-head">
<strong>{asset.name}</strong> <strong>{asset.name}</strong>
<span>{formatAssetStatus(asset.status)}</span> <span>{formatAssetStatus(asset.status)}</span>
</div> </div>
<p>{asset.description}</p> <p>{asset.description}</p>
<div className="profile-page__list-card-meta"> <div className="profile-page__list-card-meta">
<span>{asset.type}</span> <span>{formatAssetType(asset.type)}</span>
<span>{formatProfileDate(asset.updatedAt)}</span> <span>{formatProfileDate(asset.updatedAt)}</span>
</div> </div>
</div>
</article> </article>
))} ))}
</div> </div>
@@ -791,6 +840,50 @@ function ProfilePage({
</div> </div>
</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__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>
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan"> <button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined /> <ShareAltOutlined />
{packageLabel} {packageLabel}
@@ -838,52 +931,6 @@ function ProfilePage({
</span> </span>
{renderActivePanel()} {renderActivePanel()}
</div> </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> </main>
</div> </div>
</section> </section>
@@ -405,6 +405,7 @@ function ScriptTokensPage() {
<div className="script-eval-v5-page"> <div className="script-eval-v5-page">
{/* Left Panel */} {/* Left Panel */}
<aside className="script-eval-v5-left"> <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-section">
<div className="script-eval-v5-lp-label"></div> <div className="script-eval-v5-lp-label"></div>
<div <div
@@ -511,6 +512,7 @@ function ScriptTokensPage() {
<span></span> <span></span>
</button> </button>
</div> </div>
</div>
</aside> </aside>
{/* Right Area */} {/* Right Area */}
@@ -271,9 +271,8 @@ function TokenUsagePage({
) : null} ) : null}
<section className="management-metric-cards" aria-label="关键指标"> <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}`}> <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> <span className="management-metric-card__label">{card.label}</span>
<strong className="management-metric-card__value">{card.value}</strong> <strong className="management-metric-card__value">{card.value}</strong>
<span className="management-metric-card__hint">{card.hint}</span> <span className="management-metric-card__hint">{card.hint}</span>
+42 -4
View File
@@ -8614,9 +8614,8 @@
} }
.product-clone-page[data-tool="clone"] .clone-ai-logo { .product-clone-page[data-tool="clone"] .clone-ai-logo {
position: sticky; position: static;
top: 0; z-index: auto;
z-index: 3;
margin: -18px -18px 2px; margin: -18px -18px 2px;
padding: 16px 18px 14px; padding: 16px 18px 14px;
border-bottom-color: var(--ecm-line); border-bottom-color: var(--ecm-line);
@@ -9141,7 +9140,7 @@
} }
.product-clone-page[data-tool="clone"] .clone-ai-logo { .product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: -14px -14px 0; margin: 0;
padding: 14px 54px 12px 14px; padding: 14px 54px 12px 14px;
} }
@@ -9420,3 +9419,42 @@
padding-top: 14px; padding-top: 14px;
} }
} }
/* Mobile clone header alignment: keep the tool title in normal flow, but attach it to the top nav rhythm. */
@media (max-width: 900px) {
.product-clone-page[data-tool="clone"] {
padding-top: 59px;
}
.product-clone-page[data-tool="clone"] > .product-clone-shell {
min-height: calc(100% - 59px);
}
.product-clone-page[data-tool="clone"] .clone-ai-panel {
padding-top: 0;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: 0 -18px 2px;
}
}
@media (max-width: 620px) {
.product-clone-page[data-tool="clone"] .clone-ai-panel {
padding: 0 14px 14px;
}
.product-clone-page[data-tool="clone"] .clone-ai-logo {
margin: 0 -14px 0;
}
}
@media (max-width: 480px) {
.product-clone-page[data-tool="clone"] {
padding-top: 59px;
}
.product-clone-page[data-tool="clone"] > .product-clone-shell {
min-height: calc(100% - 59px);
}
}
+511
View File
@@ -3425,3 +3425,514 @@
font-size: 13px; font-size: 13px;
} }
} }
<<<<<<< HEAD
=======
/* Script review left panel overflow guard: keep actions available while history remains scrollable. */
.script-eval-v5-left {
overflow: hidden;
}
.script-eval-v5-left-main {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgb(0 255 136 / 35%) transparent;
}
.script-eval-v5-left-main::-webkit-scrollbar,
.script-eval-v5-history-list::-webkit-scrollbar {
width: 6px;
}
.script-eval-v5-left-main::-webkit-scrollbar-track,
.script-eval-v5-history-list::-webkit-scrollbar-track {
background: transparent;
}
.script-eval-v5-left-main::-webkit-scrollbar-thumb,
.script-eval-v5-history-list::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgb(0 255 136 / 28%);
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
flex: 0 0 auto;
min-height: 210px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 128px;
max-height: clamp(160px, 28vh, 300px);
overflow-y: auto;
}
.script-eval-v5-lp-bottom {
position: static;
z-index: auto;
flex-shrink: 0;
margin-top: 0;
}
@media (max-height: 820px) and (min-width: 901px) {
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
flex-basis: auto;
min-height: 190px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 118px;
max-height: clamp(142px, 23vh, 220px);
}
}
@media (max-width: 900px) {
.script-eval-v5-left-main {
overscroll-behavior: contain;
}
}
@media (max-width: 680px) {
.script-eval-v5-left {
overflow: visible;
}
.script-eval-v5-left-main {
flex: 0 0 auto;
overflow: visible;
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
min-height: 224px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 132px;
max-height: min(260px, 42vh);
}
.script-eval-v5-history-empty {
min-height: 118px;
}
}
/* Final commercial polish for the script scoring workspace. */
.script-eval-v5 {
background:
radial-gradient(circle at 12% 0%, rgb(0 255 136 / 5%), transparent 28%),
linear-gradient(180deg, #0d1010 0%, #090b0b 100%);
}
.script-eval-v5-page {
background:
linear-gradient(90deg, rgb(0 255 136 / 4%), transparent 24%),
linear-gradient(180deg, rgb(255 255 255 / 1.8%), transparent 180px);
}
.script-eval-v5-left {
background:
linear-gradient(180deg, rgb(255 255 255 / 4%), transparent 180px),
linear-gradient(90deg, rgb(0 255 136 / 4%), transparent 32%),
var(--v5-panel);
}
.script-eval-v5-left-main {
scroll-padding-block: 18px;
}
.script-eval-v5-left-main .script-eval-v5-lp-section {
flex-shrink: 0;
padding-inline: 22px;
background:
linear-gradient(180deg, rgb(255 255 255 / 1.8%), transparent 80px);
}
.script-eval-v5-left-main .script-eval-v5-lp-section + .script-eval-v5-lp-section {
box-shadow: inset 0 1px 0 rgb(255 255 255 / 2.5%);
}
.script-eval-v5-lp-label {
color: #91a09b;
}
.script-eval-v5-upload-zone {
display: grid;
place-items: center;
overflow: hidden;
isolation: isolate;
}
.script-eval-v5-upload-zone::after {
content: "";
position: absolute;
inset: 1px;
z-index: -1;
border-radius: inherit;
background:
radial-gradient(circle at 50% 18%, rgb(0 255 136 / 11%), transparent 38%),
linear-gradient(180deg, rgb(255 255 255 / 2%), transparent 60%);
opacity: 0.78;
pointer-events: none;
}
.script-eval-v5-upload-zone:focus-visible {
outline: 2px solid rgb(0 255 136 / 42%);
outline-offset: 3px;
}
.script-eval-v5.is-ready .script-eval-v5-upload-zone,
.script-eval-v5.is-complete .script-eval-v5-upload-zone {
border-color: rgb(0 255 136 / 28%);
background:
linear-gradient(180deg, rgb(0 255 136 / 8%), rgb(255 255 255 / 2.5%)),
rgb(255 255 255 / 2.8%);
}
.script-eval-v5-upload-done {
width: min(100%, 320px);
padding: 14px 14px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%);
}
.script-eval-v5-info-grid {
display: grid;
grid-template-columns: 1fr;
}
.script-eval-v5-info-item {
min-height: 42px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%);
}
.script-eval-v5-info-empty,
.script-eval-v5-history-empty {
color: #82918c;
background:
linear-gradient(180deg, rgb(255 255 255 / 3.2%), rgb(255 255 255 / 1.8%));
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
background:
linear-gradient(180deg, rgb(0 255 136 / 3.4%), transparent 92px),
linear-gradient(180deg, rgb(255 255 255 / 1.8%), transparent);
}
.script-eval-v5-history-list {
padding: 2px 8px 2px 0;
}
.script-eval-v5-history-item {
min-height: 68px;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 3%);
}
.script-eval-v5-lp-bottom {
padding: 18px 22px 22px;
background:
linear-gradient(180deg, rgb(255 255 255 / 2.2%), transparent 60px),
#111414;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 3.5%);
}
.script-eval-v5-export-btn {
border-color: rgb(255 255 255 / 7%);
background:
linear-gradient(180deg, rgb(255 255 255 / 3.5%), rgb(255 255 255 / 1.8%)),
#111414;
color: #7f8d88;
}
.script-eval-v5-export-btn:not(:disabled):hover {
border-color: rgb(0 255 136 / 22%);
color: #c7d5d0;
background:
linear-gradient(180deg, rgb(0 255 136 / 8%), rgb(255 255 255 / 2%)),
#111414;
}
.script-eval-v5-eval-btn:disabled,
.script-eval-v5-export-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
}
.script-eval-v5-right-topbar {
backdrop-filter: blur(14px);
background:
linear-gradient(180deg, rgb(18 22 21 / 92%), rgb(12 14 14 / 88%));
}
.script-eval-v5-right-content:not(.is-report) {
background:
radial-gradient(circle at 50% 43%, rgb(0 255 136 / 5%), transparent 32%),
linear-gradient(180deg, transparent, rgb(0 0 0 / 12%));
}
.script-eval-v5-upload-card-title {
color: #f0fff8;
}
.script-eval-v5-upload-card-desc {
max-width: 540px;
color: #96a5a0;
}
.script-eval-v5-statusbar {
background:
linear-gradient(180deg, rgb(17 20 20 / 84%), rgb(10 12 12 / 92%));
}
@media (max-height: 760px) and (min-width: 901px) {
.script-eval-v5-left-main .script-eval-v5-lp-section {
padding-block: 12px;
}
.script-eval-v5-upload-zone {
min-height: 156px;
}
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
min-height: 176px;
}
.script-eval-v5-left-main .script-eval-v5-history-list {
min-height: 110px;
}
}
@media (max-width: 680px) {
.script-eval-v5-left-main .script-eval-v5-lp-section {
padding-inline: 16px;
}
.script-eval-v5-upload-zone {
min-height: 164px;
}
.script-eval-v5-lp-bottom {
padding: 14px 16px 18px;
}
.script-eval-v5-right-content:not(.is-report) {
padding-top: 22px;
}
}
/* Ecommerce-aligned tone pass: restrained dark SaaS surfaces, no depth shadows. */
.script-eval-v5 {
--v5-bg: #0d0d0f;
--v5-bg2: #151719;
--v5-bg3: #181b1d;
--v5-bg4: #1d2022;
--v5-bg5: #222629;
--v5-border: rgba(255, 255, 255, 0.08);
--v5-border2: rgba(255, 255, 255, 0.12);
--v5-panel: #151719;
--v5-panel-2: #181b1d;
--v5-panel-3: #101214;
--v5-line: rgba(255, 255, 255, 0.08);
--v5-line-strong: rgba(0, 255, 136, 0.24);
--v5-green-deep: rgba(0, 255, 136, 0.055);
--v5-green-soft: rgba(0, 255, 136, 0.09);
--v5-green-border: rgba(0, 255, 136, 0.24);
--v5-shadow-soft: none;
--v5-shadow-tight: none;
background:
radial-gradient(circle at 24% 0%, rgba(0, 255, 136, 0.038), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 160px),
var(--v5-bg);
}
.script-eval-v5-page {
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.014), transparent 24%, transparent 76%, rgba(255, 255, 255, 0.012)),
transparent;
}
.script-eval-v5-left,
.script-eval-v5-right {
background: var(--v5-panel);
box-shadow: none;
}
.script-eval-v5-left {
border-right-color: var(--v5-line);
}
.script-eval-v5-left-main .script-eval-v5-lp-section,
.script-eval-v5-left-main .script-eval-v5-lp-section.is-fill {
background: transparent;
border-bottom-color: var(--v5-line);
box-shadow: none;
}
.script-eval-v5-lp-label {
color: #a7b3af;
letter-spacing: 0.02em;
}
.script-eval-v5-lp-label::before {
background: var(--v5-green);
box-shadow: none;
opacity: 0.72;
}
.script-eval-v5-upload-zone,
.script-eval-v5-info-empty,
.script-eval-v5-history-empty,
.script-eval-v5-info-item,
.script-eval-v5-history-item,
.script-eval-v5-loading,
.script-eval-v5-illustration-hit,
.script-eval-report__score-block,
.script-eval-report__chart-card,
.script-eval-report__path-card,
.script-eval-report__finding-group p {
border-color: var(--v5-line);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.032), transparent 58%),
var(--v5-panel-2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
}
.script-eval-v5-upload-zone {
border-style: dashed;
}
.script-eval-v5-upload-zone::after {
display: none;
}
.script-eval-v5-upload-zone:hover,
.script-eval-v5-upload-zone:focus-visible,
.script-eval-v5.is-ready .script-eval-v5-upload-zone,
.script-eval-v5.is-complete .script-eval-v5-upload-zone {
border-color: var(--v5-green-border);
background:
radial-gradient(circle at 50% 0%, rgba(0, 255, 136, 0.075), transparent 58%),
var(--v5-panel-3);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.028);
}
.script-eval-v5-upload-icon,
.script-eval-v5-upload-card-icon {
border-color: rgba(0, 255, 136, 0.18);
border-radius: 10px;
background: rgba(0, 255, 136, 0.09);
box-shadow: none;
}
.script-eval-v5-upload-btn,
.script-eval-v5-eval-btn {
background: var(--v5-green);
color: #061014;
box-shadow: none;
}
.script-eval-v5-upload-btn:hover,
.script-eval-v5-eval-btn:hover:not(:disabled) {
background: var(--v5-green-dim);
transform: none;
box-shadow: none;
}
.script-eval-v5-upload-done,
.script-eval-v5-history-item.is-active,
.script-eval-v5-error,
.script-eval-report__chart-note,
.script-eval-report__grade {
box-shadow: none;
}
.script-eval-v5-upload-done {
border-color: var(--v5-green-border);
background:
linear-gradient(180deg, rgba(0, 255, 136, 0.085), rgba(0, 255, 136, 0.035)),
var(--v5-panel-2);
}
.script-eval-v5-history-item:hover {
border-color: rgba(255, 255, 255, 0.13);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 58%),
var(--v5-panel-2);
transform: none;
box-shadow: none;
}
.script-eval-v5-history-item.is-active {
border-color: var(--v5-green-border);
background:
linear-gradient(90deg, rgba(0, 255, 136, 0.08), rgba(0, 255, 136, 0.025)),
var(--v5-panel-2);
}
.script-eval-v5-lp-bottom,
.script-eval-v5-right-topbar,
.script-eval-v5-statusbar {
background: rgba(21, 23, 25, 0.96);
border-color: var(--v5-line);
box-shadow: none;
backdrop-filter: none;
}
.script-eval-v5-export-btn,
.script-eval-v5-action-btn,
.script-eval-v5-retry-btn {
border-color: var(--v5-line);
background: rgba(255, 255, 255, 0.035);
color: #aeb8b1;
box-shadow: none;
}
.script-eval-v5-export-btn:hover:not(:disabled),
.script-eval-v5-action-btn:hover,
.script-eval-v5-retry-btn:hover {
border-color: var(--v5-green-border);
background: rgba(0, 255, 136, 0.07);
color: #d9fff0;
}
.script-eval-v5-right-content:not(.is-report) {
background:
radial-gradient(circle at 50% 0%, rgba(0, 255, 136, 0.034), transparent 44%),
transparent;
}
.script-eval-v5-illustration-hit:hover,
.script-eval-v5-illustration-hit:focus-visible {
background:
linear-gradient(180deg, rgba(0, 255, 136, 0.06), transparent 58%),
var(--v5-panel-2);
box-shadow: none;
}
.script-eval-report {
--report-bg: #0d0d0f;
--report-panel: #151719;
--report-panel-2: #101214;
--report-row: #181b1d;
--report-border: rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent 180px),
var(--report-bg);
}
.script-eval-report::before,
.script-eval-report::after {
opacity: 0.28;
}
.script-eval-report__bar-fill {
box-shadow: none;
}
.script-eval-v5.is-complete .script-eval-v5-status-dot,
.script-eval-v5.is-ready .script-eval-v5-status-dot {
box-shadow: none;
}
>>>>>>> c1c4086383ddd7c1c8c152c2d5a97a4f432fa260
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineConfig(() => ({
drop: ["console", "debugger"], drop: ["console", "debugger"],
}, },
build: { build: {
sourcemap: "hidden", sourcemap: false,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks(id: string) { manualChunks(id: string) {