Merge pull request 'Feat/commercial saas polish' (#12) from feat/commercial-saas-polish into master

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-06-04 10:09:15 +00:00
7 changed files with 3975 additions and 191 deletions
+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}>
+130 -83
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,21 +624,49 @@ 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">
<div className="profile-page__list-card-head"> {renderCardPreview(task.outputUrl, task.type === "video" ? "video" : "image", formatTaskType(task.type))}
<strong>{task.title}</strong> <div className="profile-page__list-card-body">
<span>{formatTaskType(task.type)}</span> <div className="profile-page__list-card-head">
</div> <strong>{task.title}</strong>
<p>{task.prompt}</p> </div>
<div className="profile-page__list-card-meta"> <p>{task.prompt}</p>
<span>{formatTaskStatus(task.status)}</span> <div className="profile-page__list-card-meta">
<span>{formatProfileDate(task.createdAt)}</span> <span>{formatTaskStatus(task.status)}</span>
<span>{formatProfileDate(task.createdAt)}</span>
</div>
</div> </div>
</article> </article>
))} ))}
@@ -637,25 +681,27 @@ 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">
<div className="profile-page__list-card-head"> {renderCardPreview(project.thumbnailUrl, "project", "项目")}
<strong>{project.name}</strong> <div className="profile-page__list-card-body">
<span>{formatProfileDate(project.updatedAt)}</span> <div className="profile-page__list-card-head">
{onDeleteProject ? ( <strong>{project.name}</strong>
<button {onDeleteProject ? (
type="button" <button
className="profile-page__delete-project" type="button"
aria-label={`删除项目 ${project.name}`} className="profile-page__delete-project"
onClick={() => onDeleteProject(project)} aria-label={`删除项目 ${project.name}`}
> onClick={() => onDeleteProject(project)}
<DeleteOutlined /> >
</button> <DeleteOutlined />
) : null} </button>
</div> ) : null}
<p>{project.description || "最近更新的项目"}</p> </div>
<div className="profile-page__list-card-meta"> <p>{project.description || "最近更新的项目"}</p>
<span>{project.storyboardCount} </span> <div className="profile-page__list-card-meta">
<span>{project.imageCount} / {project.videoCount} </span> <span>{project.storyboardCount} </span>
<span>{formatProfileDate(project.updatedAt)}</span>
</div>
</div> </div>
</article> </article>
))} ))}
@@ -669,15 +715,18 @@ 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">
<div className="profile-page__list-card-head"> {renderCardPreview(asset.imageUrl || asset.url, asset.type === "video" ? "video" : "asset", formatAssetType(asset.type))}
<strong>{asset.name}</strong> <div className="profile-page__list-card-body">
<span>{formatAssetStatus(asset.status)}</span> <div className="profile-page__list-card-head">
</div> <strong>{asset.name}</strong>
<p>{asset.description}</p> <span>{formatAssetStatus(asset.status)}</span>
<div className="profile-page__list-card-meta"> </div>
<span>{asset.type}</span> <p>{asset.description}</p>
<span>{formatProfileDate(asset.updatedAt)}</span> <div className="profile-page__list-card-meta">
<span>{formatAssetType(asset.type)}</span>
<span>{formatProfileDate(asset.updatedAt)}</span>
</div>
</div> </div>
</article> </article>
))} ))}
@@ -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>
+99 -97
View File
@@ -405,111 +405,113 @@ 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-lp-section"> <div className="script-eval-v5-left-main">
<div className="script-eval-v5-lp-label"></div> <div className="script-eval-v5-lp-section">
<div <div className="script-eval-v5-lp-label"></div>
className="script-eval-v5-upload-zone" <div
role="button" className="script-eval-v5-upload-zone"
tabIndex={0} role="button"
onClick={() => fileInputRef.current?.click()} tabIndex={0}
onKeyDown={uploadKeyDown} onClick={() => fileInputRef.current?.click()}
> onKeyDown={uploadKeyDown}
{uploadedFile ? ( >
<div className="script-eval-v5-upload-done is-show"> {uploadedFile ? (
<CheckCircleFilled /> <div className="script-eval-v5-upload-done is-show">
<span className="script-eval-v5-uf-meta"> <CheckCircleFilled />
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span> <span className="script-eval-v5-uf-meta">
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span> <span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
</span> <span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}> </span>
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}>
</span>
</div> </span>
) : ( </div>
<> ) : (
<div className="script-eval-v5-upload-icon"><UploadOutlined /></div> <>
<div className="script-eval-v5-upload-text"></div> <div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}> <div className="script-eval-v5-upload-text"></div>
<UploadOutlined /> <button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
</button> <UploadOutlined />
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div> </button>
</> <div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
)} </>
)}
</div>
<input ref={fileInputRef} type="file" accept={TEXT_FILE_ACCEPT} style={{ display: "none" }} onChange={handleFileUpload} />
</div> </div>
<input ref={fileInputRef} type="file" accept={TEXT_FILE_ACCEPT} style={{ display: "none" }} onChange={handleFileUpload} />
</div>
<div className="script-eval-v5-lp-section"> <div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label">AI </div> <div className="script-eval-v5-lp-label">AI </div>
<div className="script-eval-v5-info-grid"> <div className="script-eval-v5-info-grid">
{!result ? ( {!result ? (
<div className="script-eval-v5-info-empty"></div> <div className="script-eval-v5-info-empty"></div>
) : ( ) : (
<> <>
<div className="script-eval-v5-info-item"> <div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span> <span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val"><span className="script-eval-v5-info-tag">{result.totalScore} · {grade}</span></span> <span className="script-eval-v5-info-val"><span className="script-eval-v5-info-tag">{result.totalScore} · {grade}</span></span>
</div> </div>
<div className="script-eval-v5-info-item"> <div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span> <span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{script.length} </span> <span className="script-eval-v5-info-val">{script.length} </span>
</div> </div>
<div className="script-eval-v5-info-item"> <div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span> <span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{new Date().toLocaleDateString("zh-CN")}</span> <span className="script-eval-v5-info-val">{new Date().toLocaleDateString("zh-CN")}</span>
</div> </div>
<div className="script-eval-v5-info-item"> <div className="script-eval-v5-info-item">
<span className="script-eval-v5-info-key"></span> <span className="script-eval-v5-info-key"></span>
<span className="script-eval-v5-info-val">{beatPct}%</span> <span className="script-eval-v5-info-val">{beatPct}%</span>
</div> </div>
</> </>
)} )}
</div>
</div> </div>
</div>
<div className="script-eval-v5-lp-section is-fill"> <div className="script-eval-v5-lp-section is-fill">
<div className="script-eval-v5-lp-label"></div> <div className="script-eval-v5-lp-label"></div>
<div className="script-eval-v5-history-list"> <div className="script-eval-v5-history-list">
{!session ? ( {!session ? (
<div className="script-eval-v5-history-empty"></div> <div className="script-eval-v5-history-empty"></div>
) : history.length === 0 ? ( ) : history.length === 0 ? (
<div className="script-eval-v5-history-empty"></div> <div className="script-eval-v5-history-empty"></div>
) : ( ) : (
history.map((item, i) => ( history.map((item, i) => (
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`} <div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`}
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0} onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0}
onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}> onKeyDown={(e) => { if ((e as React.KeyboardEvent).key === "Enter") handleHistoryClick(item, i); }}>
<div className="script-eval-v5-hi-left"> <div className="script-eval-v5-hi-left">
<div className="script-eval-v5-hi-name">{item.name}</div> <div className="script-eval-v5-hi-name">{item.name}</div>
<div className="script-eval-v5-hi-date">{item.date}</div> <div className="script-eval-v5-hi-date">{item.date}</div>
<div className="script-eval-v5-hi-bar"> <div className="script-eval-v5-hi-bar">
<div className="script-eval-v5-hi-bar-fill" style={{ width: `${Math.min(92, (item.score / 100) * 100)}%` }} /> <div className="script-eval-v5-hi-bar-fill" style={{ width: `${Math.min(92, (item.score / 100) * 100)}%` }} />
</div>
</div>
<div className="script-eval-v5-hi-right">
<div className={`script-eval-v5-hi-score${item.score >= 90 ? " is-green" : ""}`}>{item.score}</div>
<div className="script-eval-v5-hi-grade">{item.grade}</div>
</div> </div>
</div> </div>
<div className="script-eval-v5-hi-right"> ))
<div className={`script-eval-v5-hi-score${item.score >= 90 ? " is-green" : ""}`}>{item.score}</div> )}
<div className="script-eval-v5-hi-grade">{item.grade}</div> </div>
</div>
</div>
))
)}
</div> </div>
</div>
<div className="script-eval-v5-lp-bottom"> <div className="script-eval-v5-lp-bottom">
<button <button
type="button" type="button"
className="script-eval-v5-eval-btn" className="script-eval-v5-eval-btn"
disabled={loading || !hasContent} disabled={loading || !hasContent}
onClick={() => void handleEvaluate()} onClick={() => void handleEvaluate()}
> >
{loading ? <LoadingOutlined /> : <ThunderboltOutlined />} {loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
<span>{loading ? "评测中..." : "开始评测"}</span> <span>{loading ? "评测中..." : "开始评测"}</span>
</button> </button>
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}> <button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
<DownloadOutlined /> <DownloadOutlined />
<span></span> <span></span>
</button> </button>
</div>
</div> </div>
</aside> </aside>
@@ -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
@@ -8598,9 +8598,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);
@@ -9103,7 +9102,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;
} }
@@ -9382,3 +9381,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);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff