merge: 合并远程PR#12商业化打磨和PR#13修复
This commit is contained in:
@@ -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";
|
||||
@@ -3560,7 +3559,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,
|
||||
@@ -3748,9 +3748,7 @@ 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}>
|
||||
|
||||
@@ -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;
|
||||
@@ -305,6 +401,15 @@ export interface VideoHistoryScene {
|
||||
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;
|
||||
@@ -330,22 +435,74 @@ function getAuthHeaders(): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export async function saveVideoHistory(payload: {
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
plan: Record<string, unknown>;
|
||||
scenes: VideoHistoryScene[];
|
||||
sourceImageUrls: string[];
|
||||
}): Promise<{ id: number; createdAt: string }> {
|
||||
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(payload),
|
||||
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,
|
||||
@@ -355,7 +512,11 @@ export async function fetchVideoHistory(
|
||||
{ headers: getAuthHeaders() },
|
||||
);
|
||||
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> {
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FileImageOutlined,
|
||||
FolderOpenOutlined,
|
||||
LockOutlined,
|
||||
MailOutlined,
|
||||
MobileOutlined,
|
||||
PhoneOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
SafetyOutlined,
|
||||
ShareAltOutlined,
|
||||
@@ -180,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,
|
||||
@@ -608,21 +624,49 @@ 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">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
@@ -637,25 +681,27 @@ 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">
|
||||
<div className="profile-page__list-card-head">
|
||||
<strong>{project.name}</strong>
|
||||
<span>{formatProfileDate(project.updatedAt)}</span>
|
||||
{onDeleteProject ? (
|
||||
<button
|
||||
type="button"
|
||||
className="profile-page__delete-project"
|
||||
aria-label={`删除项目 ${project.name}`}
|
||||
onClick={() => onDeleteProject(project)}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<p>{project.description || "最近更新的项目"}</p>
|
||||
<div className="profile-page__list-card-meta">
|
||||
<span>{project.storyboardCount} 节点</span>
|
||||
<span>{project.imageCount} 图 / {project.videoCount} 视频</span>
|
||||
<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>
|
||||
{onDeleteProject ? (
|
||||
<button
|
||||
type="button"
|
||||
className="profile-page__delete-project"
|
||||
aria-label={`删除项目 ${project.name}`}
|
||||
onClick={() => onDeleteProject(project)}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<p>{project.description || "最近更新的项目"}</p>
|
||||
<div className="profile-page__list-card-meta">
|
||||
<span>{project.storyboardCount} 节点</span>
|
||||
<span>{formatProfileDate(project.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -669,15 +715,18 @@ function ProfilePage({
|
||||
return savedAssets.length ? (
|
||||
<div className="profile-page__list-grid">
|
||||
{savedAssets.map((asset) => (
|
||||
<article key={asset.id} className="profile-page__list-card">
|
||||
<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>{formatProfileDate(asset.updatedAt)}</span>
|
||||
<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>{formatAssetType(asset.type)}</span>
|
||||
<span>{formatProfileDate(asset.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -791,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__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">
|
||||
<ShareAltOutlined />
|
||||
{packageLabel}
|
||||
@@ -838,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>
|
||||
|
||||
@@ -405,111 +405,113 @@ function ScriptTokensPage() {
|
||||
<div className="script-eval-v5-page">
|
||||
{/* Left Panel */}
|
||||
<aside className="script-eval-v5-left">
|
||||
<div className="script-eval-v5-lp-section">
|
||||
<div className="script-eval-v5-lp-label">上传剧本</div>
|
||||
<div
|
||||
className="script-eval-v5-upload-zone"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onKeyDown={uploadKeyDown}
|
||||
>
|
||||
{uploadedFile ? (
|
||||
<div className="script-eval-v5-upload-done is-show">
|
||||
<CheckCircleFilled />
|
||||
<span className="script-eval-v5-uf-meta">
|
||||
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
|
||||
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
|
||||
</span>
|
||||
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}>
|
||||
重新上传
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
|
||||
<div className="script-eval-v5-upload-text">拖拽或点击上传</div>
|
||||
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
|
||||
<UploadOutlined /> 选择剧本
|
||||
</button>
|
||||
<div className="script-eval-v5-upload-hint">{TEXT_FILE_HINT}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="script-eval-v5-left-main">
|
||||
<div className="script-eval-v5-lp-section">
|
||||
<div className="script-eval-v5-lp-label">上传剧本</div>
|
||||
<div
|
||||
className="script-eval-v5-upload-zone"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onKeyDown={uploadKeyDown}
|
||||
>
|
||||
{uploadedFile ? (
|
||||
<div className="script-eval-v5-upload-done is-show">
|
||||
<CheckCircleFilled />
|
||||
<span className="script-eval-v5-uf-meta">
|
||||
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
|
||||
<span className="script-eval-v5-uf-size">{formatFileSize(uploadedFile.size)}</span>
|
||||
</span>
|
||||
<span className="script-eval-v5-uf-re" onClick={(e) => { e.stopPropagation(); handleReset(); }}>
|
||||
重新上传
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="script-eval-v5-upload-icon"><UploadOutlined /></div>
|
||||
<div className="script-eval-v5-upload-text">拖拽或点击上传</div>
|
||||
<button type="button" className="script-eval-v5-upload-btn" onClick={(e) => { e.stopPropagation(); fileInputRef.current?.click(); }}>
|
||||
<UploadOutlined /> 选择剧本
|
||||
</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>
|
||||
<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-label">AI 识别信息</div>
|
||||
<div className="script-eval-v5-info-grid">
|
||||
{!result ? (
|
||||
<div className="script-eval-v5-info-empty">上传剧本并点击评测后识别</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="script-eval-v5-info-item">
|
||||
<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>
|
||||
</div>
|
||||
<div className="script-eval-v5-info-item">
|
||||
<span className="script-eval-v5-info-key">文本长度</span>
|
||||
<span className="script-eval-v5-info-val">{script.length} 字</span>
|
||||
</div>
|
||||
<div className="script-eval-v5-info-item">
|
||||
<span className="script-eval-v5-info-key">评测时间</span>
|
||||
<span className="script-eval-v5-info-val">{new Date().toLocaleDateString("zh-CN")}</span>
|
||||
</div>
|
||||
<div className="script-eval-v5-info-item">
|
||||
<span className="script-eval-v5-info-key">击败比例</span>
|
||||
<span className="script-eval-v5-info-val">{beatPct}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="script-eval-v5-lp-section">
|
||||
<div className="script-eval-v5-lp-label">AI 识别信息</div>
|
||||
<div className="script-eval-v5-info-grid">
|
||||
{!result ? (
|
||||
<div className="script-eval-v5-info-empty">上传剧本并点击评测后识别</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="script-eval-v5-info-item">
|
||||
<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>
|
||||
</div>
|
||||
<div className="script-eval-v5-info-item">
|
||||
<span className="script-eval-v5-info-key">文本长度</span>
|
||||
<span className="script-eval-v5-info-val">{script.length} 字</span>
|
||||
</div>
|
||||
<div className="script-eval-v5-info-item">
|
||||
<span className="script-eval-v5-info-key">评测时间</span>
|
||||
<span className="script-eval-v5-info-val">{new Date().toLocaleDateString("zh-CN")}</span>
|
||||
</div>
|
||||
<div className="script-eval-v5-info-item">
|
||||
<span className="script-eval-v5-info-key">击败比例</span>
|
||||
<span className="script-eval-v5-info-val">{beatPct}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="script-eval-v5-lp-section is-fill">
|
||||
<div className="script-eval-v5-lp-label">历史评测</div>
|
||||
<div className="script-eval-v5-history-list">
|
||||
{!session ? (
|
||||
<div className="script-eval-v5-history-empty">登录后查看云端评测记录</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="script-eval-v5-history-empty">暂无评测记录</div>
|
||||
) : (
|
||||
history.map((item, i) => (
|
||||
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`}
|
||||
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0}
|
||||
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-name">{item.name}</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-fill" style={{ width: `${Math.min(92, (item.score / 100) * 100)}%` }} />
|
||||
<div className="script-eval-v5-lp-section is-fill">
|
||||
<div className="script-eval-v5-lp-label">历史评测</div>
|
||||
<div className="script-eval-v5-history-list">
|
||||
{!session ? (
|
||||
<div className="script-eval-v5-history-empty">登录后查看云端评测记录</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="script-eval-v5-history-empty">暂无评测记录</div>
|
||||
) : (
|
||||
history.map((item, i) => (
|
||||
<div key={i} className={`script-eval-v5-history-item${i === activeHistoryIndex ? " is-active" : ""}`}
|
||||
onClick={() => handleHistoryClick(item, i)} role="button" tabIndex={0}
|
||||
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-name">{item.name}</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-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 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 className="script-eval-v5-lp-bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="script-eval-v5-eval-btn"
|
||||
disabled={loading || !hasContent}
|
||||
onClick={() => void handleEvaluate()}
|
||||
>
|
||||
{loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
|
||||
<span>{loading ? "评测中..." : "开始评测"}</span>
|
||||
</button>
|
||||
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
|
||||
<DownloadOutlined />
|
||||
<span>导出评测报告</span>
|
||||
</button>
|
||||
<div className="script-eval-v5-lp-bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="script-eval-v5-eval-btn"
|
||||
disabled={loading || !hasContent}
|
||||
onClick={() => void handleEvaluate()}
|
||||
>
|
||||
{loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
|
||||
<span>{loading ? "评测中..." : "开始评测"}</span>
|
||||
</button>
|
||||
<button type="button" className="script-eval-v5-export-btn" disabled={!result} onClick={handleExportMarkdown}>
|
||||
<DownloadOutlined />
|
||||
<span>导出评测报告</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user