Merge branch 'master' into feat/home-responsive-polish-and-ui-refinements
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type JSX } from "react";
|
||||
import "../../styles/pages/assets.css";
|
||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
@@ -95,6 +95,17 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; asset: LibraryAssetItem } | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploadDragging, setIsUploadDragging] = useState(false);
|
||||
|
||||
const handleUploadDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsUploadDragging(true); };
|
||||
const handleUploadDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsUploadDragging(false); };
|
||||
const handleUploadDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsUploadDragging(false);
|
||||
if (e.dataTransfer.files.length) {
|
||||
void handleUploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, asset: LibraryAssetItem) => {
|
||||
e.preventDefault();
|
||||
@@ -270,7 +281,15 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) {
|
||||
placeholder="搜索资产..."
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="studio-generate-btn studio-generate-btn--compact" onClick={() => uploadInputRef.current?.click()} disabled={isUploading}>
|
||||
<button
|
||||
type="button"
|
||||
className={`studio-generate-btn studio-generate-btn--compact${isUploadDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
onDragOver={handleUploadDragOver}
|
||||
onDragLeave={handleUploadDragLeave}
|
||||
onDrop={handleUploadDrop}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? <LoadingOutlined /> : <PlusOutlined />}
|
||||
{isUploading ? "上传中..." : "添加"}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExperimentOutlined,
|
||||
FileSearchOutlined,
|
||||
LoginOutlined,
|
||||
ReloadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { betaApplicationClient, type BetaApplicationItem, type BetaApplicationStatus } from "../../api/betaApplicationClient";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import type { WebUserSession } from "../../types";
|
||||
import "../../styles/pages/beta-applications.css";
|
||||
|
||||
interface BetaApplicationsPageProps {
|
||||
session: WebUserSession | null;
|
||||
onOpenLogin: () => void;
|
||||
}
|
||||
|
||||
type StatusFilter = BetaApplicationStatus | "";
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: StatusFilter; label: string }> = [
|
||||
{ value: "pending", label: "待审核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "已驳回" },
|
||||
{ value: "", label: "全部" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<BetaApplicationStatus, string> = {
|
||||
pending: "待审核",
|
||||
approved: "已通过",
|
||||
rejected: "已驳回",
|
||||
};
|
||||
|
||||
function canReviewBetaApplications(session: WebUserSession | null): boolean {
|
||||
const role = String(session?.user.role || "").trim().toLowerCase();
|
||||
const username = String(session?.user.username || "").trim().toLowerCase();
|
||||
return role === "admin" || username === "xqy1912";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "暂无时间";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function valueOrEmpty(value?: string | null): string {
|
||||
return value?.trim() || "未填写";
|
||||
}
|
||||
|
||||
function joinValues(values: string[]): string {
|
||||
return values.length ? values.join("、") : "未选择";
|
||||
}
|
||||
|
||||
function DetailField({ label, value, wide }: { label: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<div className={`beta-admin-field${wide ? " beta-admin-field--wide" : ""}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BetaApplicationsPage({ session, onOpenLogin }: BetaApplicationsPageProps) {
|
||||
const allowed = canReviewBetaApplications(session);
|
||||
const [status, setStatus] = useState<StatusFilter>("pending");
|
||||
const [applications, setApplications] = useState<BetaApplicationItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [reviewNote, setReviewNote] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedApplication = useMemo(
|
||||
() => applications.find((item) => item.id === selectedId) ?? applications[0] ?? null,
|
||||
[applications, selectedId],
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!allowed) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const items = await betaApplicationClient.listAdminApplications(status);
|
||||
setApplications(items);
|
||||
setSelectedId((current) =>
|
||||
current && items.some((item) => item.id === current) ? current : (items[0]?.id ?? null),
|
||||
);
|
||||
} catch (loadError) {
|
||||
setApplications([]);
|
||||
setError(loadError instanceof Error ? loadError.message : "内测申请列表加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [allowed, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const handleDecision = async (action: "approve" | "reject") => {
|
||||
if (!selectedApplication || selectedApplication.status !== "pending" || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await betaApplicationClient.reviewApplication(selectedApplication.id, action, reviewNote.trim());
|
||||
setReviewNote("");
|
||||
await load();
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "审核操作失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<section className="beta-admin-access">
|
||||
<LoginOutlined />
|
||||
<h1>请登录审核账号</h1>
|
||||
<p>内测申请审核仅开放给管理员和 xqy1912。</p>
|
||||
<button type="button" onClick={onOpenLogin}>登录 / 注册</button>
|
||||
</section>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<section className="beta-admin-access">
|
||||
<FileSearchOutlined />
|
||||
<h1>当前账号没有审核权限</h1>
|
||||
<p>请切换到 admin 或 xqy1912 后再进入内测审核台。</p>
|
||||
</section>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspacePageShell title="内测申请审核" fullWidth className="beta-admin-page page-motion">
|
||||
<div className="beta-admin-page__inner">
|
||||
<section className="beta-admin-toolbar">
|
||||
<div>
|
||||
<span>内部审核台</span>
|
||||
<h1>内测申请表</h1>
|
||||
<p>查看用户提交的完整申请资料,通过后发放内测码,驳回后向用户发送未通过通知。</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void load()} disabled={loading}>
|
||||
<ReloadOutlined />
|
||||
刷新
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="beta-admin-status-tabs" role="tablist" aria-label="内测申请状态">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value || "all"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={status === option.value}
|
||||
className={status === option.value ? "is-active" : ""}
|
||||
onClick={() => setStatus(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? <p className="beta-admin-error">{error}</p> : null}
|
||||
|
||||
<section className="beta-admin-layout">
|
||||
<aside className="beta-admin-list" aria-label="内测申请列表">
|
||||
{loading ? <div className="beta-admin-list__empty">正在加载申请...</div> : null}
|
||||
{!loading && applications.length === 0 ? (
|
||||
<div className="beta-admin-list__empty">暂无需要显示的申请</div>
|
||||
) : null}
|
||||
{applications.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`beta-admin-list__item${item.id === selectedApplication?.id ? " is-active" : ""}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<span className={`beta-admin-status beta-admin-status--${item.status}`}>{STATUS_LABEL[item.status]}</span>
|
||||
<strong>{item.name || item.username || `申请 #${item.id}`}</strong>
|
||||
<small>{item.industry || "未填写行业"} · {formatDate(item.createdAt)}</small>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{selectedApplication ? (
|
||||
<article className="beta-admin-detail">
|
||||
<header className="beta-admin-detail__header">
|
||||
<div>
|
||||
<span><ExperimentOutlined /> {STATUS_LABEL[selectedApplication.status]}</span>
|
||||
<h2>{selectedApplication.name || "未填写姓名"}</h2>
|
||||
<p>{selectedApplication.selfStatement || "申请人未填写自述。"}</p>
|
||||
</div>
|
||||
{selectedApplication.inviteCode ? (
|
||||
<strong className="beta-admin-code">内测码:{selectedApplication.inviteCode}</strong>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>一、个人基础信息</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="姓名 / 常用昵称" value={valueOrEmpty(selectedApplication.name)} />
|
||||
<DetailField label="接收内测码邮箱" value={valueOrEmpty(selectedApplication.email)} />
|
||||
<DetailField label="联系手机号码" value={valueOrEmpty(selectedApplication.phone)} />
|
||||
<DetailField label="微信账号" value={valueOrEmpty(selectedApplication.wechat)} />
|
||||
<DetailField label="所在行业 / 职业" value={valueOrEmpty(selectedApplication.industry)} />
|
||||
<DetailField label="所属公司 / 机构" value={valueOrEmpty(selectedApplication.company)} />
|
||||
<DetailField label="所在城市" value={valueOrEmpty(selectedApplication.city)} />
|
||||
<DetailField label="关联账号" value={selectedApplication.username || `UID ${selectedApplication.userId ?? "未登录提交"}`} />
|
||||
<DetailField label="提交时间" value={formatDate(selectedApplication.createdAt)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>二、AI 从业与使用经历</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="常用 AI 创作工具" value={valueOrEmpty(selectedApplication.aiTools)} wide />
|
||||
<DetailField label="AI 内容创作从业时长" value={valueOrEmpty(selectedApplication.aiDuration)} />
|
||||
<DetailField label="是否深耕相关赛道" value={valueOrEmpty(selectedApplication.aiTrack)} />
|
||||
<DetailField label="日常主要创作方向" value={joinValues(selectedApplication.aiDirection)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>三、内测使用意向调研</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="每周稳定使用次数" value={valueOrEmpty(selectedApplication.weeklyUsage)} />
|
||||
<DetailField label="反馈意愿" value={valueOrEmpty(selectedApplication.feedbackWilling)} />
|
||||
<DetailField label="最想体验功能" value={joinValues(selectedApplication.wantFeature)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>四、申请自述与确认</h3>
|
||||
<p className="beta-admin-statement">{selectedApplication.selfStatement || "未填写"}</p>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="申请人确认签字" value={valueOrEmpty(selectedApplication.signature)} />
|
||||
<DetailField label="申请填写日期" value={valueOrEmpty(selectedApplication.applicationDate)} />
|
||||
<DetailField label="同意规则" value={selectedApplication.agreeRules ? "已同意" : "未同意"} />
|
||||
<DetailField label="IP" value={valueOrEmpty(selectedApplication.ipAddress)} />
|
||||
<DetailField label="客户端" value={valueOrEmpty(selectedApplication.userAgent)} wide />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selectedApplication.status !== "pending" ? (
|
||||
<section className="beta-admin-form-card">
|
||||
<h3>审核结果</h3>
|
||||
<div className="beta-admin-field-grid">
|
||||
<DetailField label="审核人" value={selectedApplication.reviewerUsername || `UID ${selectedApplication.reviewedBy ?? "-"}`} />
|
||||
<DetailField label="审核时间" value={formatDate(selectedApplication.reviewedAt)} />
|
||||
<DetailField label="审核备注" value={valueOrEmpty(selectedApplication.reviewNote)} wide />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="beta-admin-review-box">
|
||||
<label>
|
||||
<span>审核备注</span>
|
||||
<textarea
|
||||
value={reviewNote}
|
||||
onChange={(event) => setReviewNote(event.target.value)}
|
||||
placeholder="填写通过说明或驳回原因;驳回时该备注会作为用户通知内容。"
|
||||
/>
|
||||
</label>
|
||||
<div className="beta-admin-actions">
|
||||
<button type="button" disabled={submitting} onClick={() => void handleDecision("reject")}>
|
||||
<CloseCircleOutlined />
|
||||
驳回并通知
|
||||
</button>
|
||||
<button type="button" disabled={submitting} onClick={() => void handleDecision("approve")}>
|
||||
<CheckCircleOutlined />
|
||||
通过并发放内测码
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
) : (
|
||||
<div className="beta-admin-detail beta-admin-detail--empty">选择左侧申请查看表单详情</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspacePageShell>
|
||||
);
|
||||
}
|
||||
@@ -58,13 +58,13 @@ export const defaultTextModelId = textModelOptions[0].id;
|
||||
|
||||
// --- Image model options ---
|
||||
export const imageModelOptions: CanvasOption[] = [
|
||||
{ value: "wan2.7-image", label: "wan 2.7 · 0.20 积分" },
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro · 0.20 积分" },
|
||||
{ value: "gpt-image-2", label: "GPT-Image-2 · 0.20 积分" },
|
||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP · 0.20 积分" },
|
||||
{ value: "nano-banana-pro", label: "Nano Banana Pro · 0.20 积分" },
|
||||
{ value: "nano-banana-2", label: "Nano Banana 2 · 0.20 积分" },
|
||||
{ value: "nano-banana-fast", label: "Nano Banana · 0.20 积分" },
|
||||
{ value: "wan2.7-image", label: "wan 2.7" },
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro" },
|
||||
{ value: "gpt-image-2", label: "omni-GPT" },
|
||||
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
|
||||
{ value: "nano-banana-pro", label: "omni-水果 Pro" },
|
||||
{ value: "nano-banana-2", label: "omni-水果 2" },
|
||||
{ value: "nano-banana-fast", label: "omni-水果" },
|
||||
];
|
||||
|
||||
export const imageRatioOptions: CanvasOption[] = [
|
||||
|
||||
@@ -61,6 +61,9 @@ function CharacterMixPage({
|
||||
const abortRef = useRef(false);
|
||||
const taskIdRef = useRef<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
|
||||
const characterInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -262,6 +265,23 @@ function CharacterMixPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsCanvasDragging(true); };
|
||||
const handleCanvasDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsCanvasDragging(false); };
|
||||
const handleCanvasDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsCanvasDragging(false);
|
||||
handleDrop(e);
|
||||
};
|
||||
|
||||
const handleCanvasClick = () => {
|
||||
if (!characterPreview) {
|
||||
characterInputRef.current?.click();
|
||||
} else if (!videoPreview) {
|
||||
videoInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
|
||||
<header className="image-workbench-topbar">
|
||||
@@ -342,6 +362,7 @@ function CharacterMixPage({
|
||||
<div className="studio-panel__section-body">
|
||||
<label className={characterFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||
<input
|
||||
ref={characterInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
@@ -383,6 +404,7 @@ function CharacterMixPage({
|
||||
<div className="studio-panel__section-body">
|
||||
<label className={videoFile ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={(event) => {
|
||||
@@ -441,12 +463,21 @@ function CharacterMixPage({
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-canvas-ghost">
|
||||
<div
|
||||
className={`studio-canvas-ghost${isCanvasDragging ? " is-dragging" : ""}`}
|
||||
onClick={handleCanvasClick}
|
||||
onDragOver={handleCanvasDragOver}
|
||||
onDragLeave={handleCanvasDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }}
|
||||
>
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<SwapOutlined />
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">上传人物图与参考视频</div>
|
||||
<div className="studio-canvas-ghost__hint">将静态角色迁移到参考视频的动作与表情中。</div>
|
||||
<div className="studio-canvas-ghost__hint">点击或拖拽上传;支持人物图片 (PNG/JPG) 和参考视频 (MP4/MOV/AVI)</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
PictureOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
import { communityClient } from "../../api/communityClient";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import "../../styles/pages/compliance.css";
|
||||
import type { WebCanvasWorkflow, WebUserSession } from "../../types";
|
||||
import { getWorkflowCoverUrl, isCanvasWorkflow } from "../community/communityCaseUtils";
|
||||
import { canManageCommunityCases } from "./communityPermissions";
|
||||
@@ -72,6 +73,29 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
|
||||
const allowed = canManageCommunityCases(session);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const workflowInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isImageDragging, setIsImageDragging] = useState(false);
|
||||
const [isWorkflowDragging, setIsWorkflowDragging] = useState(false);
|
||||
|
||||
const handleImageDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsImageDragging(true); };
|
||||
const handleImageDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsImageDragging(false); };
|
||||
const handleImageDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsImageDragging(false);
|
||||
if (e.dataTransfer.files.length) {
|
||||
void handleImageChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkflowDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsWorkflowDragging(true); };
|
||||
const handleWorkflowDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsWorkflowDragging(false); };
|
||||
const handleWorkflowDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsWorkflowDragging(false);
|
||||
if (e.dataTransfer.files.length) {
|
||||
void handleWorkflowChange({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
const [target, setTarget] = useState<CaseTarget>("generation");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
@@ -330,7 +354,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
|
||||
</label>
|
||||
<div className="community-case-add-upload-row">
|
||||
<input ref={imageInputRef} type="file" accept="image/*" hidden onChange={handleImageChange} />
|
||||
<button type="button" onClick={() => imageInputRef.current?.click()}>
|
||||
<button
|
||||
type="button"
|
||||
className={isImageDragging ? "is-dragging" : ""}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
onDragOver={handleImageDragOver}
|
||||
onDragLeave={handleImageDragLeave}
|
||||
onDrop={handleImageDrop}
|
||||
>
|
||||
<UploadOutlined />
|
||||
上传图片
|
||||
</button>
|
||||
@@ -344,7 +375,14 @@ export default function CommunityCaseAddPage({ session, onOpenLogin, onOpenRevie
|
||||
<>
|
||||
<div className="community-case-add-upload-row">
|
||||
<input ref={workflowInputRef} type="file" accept="application/json,.json" hidden onChange={handleWorkflowChange} />
|
||||
<button type="button" onClick={() => workflowInputRef.current?.click()}>
|
||||
<button
|
||||
type="button"
|
||||
className={isWorkflowDragging ? "is-dragging" : ""}
|
||||
onClick={() => workflowInputRef.current?.click()}
|
||||
onDragOver={handleWorkflowDragOver}
|
||||
onDragLeave={handleWorkflowDragLeave}
|
||||
onDrop={handleWorkflowDrop}
|
||||
>
|
||||
<UploadOutlined />
|
||||
上传 JSON
|
||||
</button>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||
import { reportClient, type AdminReportItem } from "../../api/reportClient";
|
||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||
import "../../styles/pages/compliance.css";
|
||||
import type { WebUserSession } from "../../types";
|
||||
import { canManageCommunityCases, canReviewCommunity } from "./communityPermissions";
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ function DigitalHumanPage({
|
||||
activeTaskIdRef.current = activeTaskId;
|
||||
const keepaliveRestoredRef = useRef(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const canvasDragCounterRef = useRef(0);
|
||||
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -171,6 +175,39 @@ function DigitalHumanPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsCanvasDragging(true); };
|
||||
const handleCanvasDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsCanvasDragging(false); };
|
||||
const handleCanvasDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsCanvasDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
if (file.type.startsWith("image/")) {
|
||||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||
setImageName(file.name);
|
||||
setImageFile(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
pushDebugEntry("选择图片", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
|
||||
setNotice(`已拖放参考图 ${file.name}`);
|
||||
} else if (file.type.startsWith("audio/")) {
|
||||
if (audioPreview) URL.revokeObjectURL(audioPreview);
|
||||
setAudioName(file.name);
|
||||
setAudioFile(file);
|
||||
setAudioPreview(URL.createObjectURL(file));
|
||||
pushDebugEntry("选择音频", `${file.name} / ${file.type || "unknown"} / ${formatFileSize(file.size)}`);
|
||||
setNotice(`已拖放音频 ${file.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasClick = () => {
|
||||
if (!imagePreview) {
|
||||
imageInputRef.current?.click();
|
||||
} else if (!audioPreview) {
|
||||
audioInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadResult = async () => {
|
||||
if (!resultVideoUrl || isDownloadingResult) return;
|
||||
setIsDownloadingResult(true);
|
||||
@@ -463,6 +500,7 @@ function DigitalHumanPage({
|
||||
<div className="studio-panel__section-body">
|
||||
<label className={imageName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(event) => {
|
||||
@@ -501,6 +539,7 @@ function DigitalHumanPage({
|
||||
<div className="studio-panel__section-body">
|
||||
<label className={audioName ? "studio-upload-slot--filled" : "studio-upload-slot--empty"}>
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={(event) => {
|
||||
@@ -541,12 +580,21 @@ function DigitalHumanPage({
|
||||
<img src={imagePreview} alt="参考人像" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-canvas-ghost">
|
||||
<div
|
||||
className={`studio-canvas-ghost${isCanvasDragging ? " is-dragging" : ""}`}
|
||||
onClick={handleCanvasClick}
|
||||
onDragOver={handleCanvasDragOver}
|
||||
onDragLeave={handleCanvasDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCanvasClick(); }}
|
||||
>
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<CustomerServiceOutlined />
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">上传参考人像与音频</div>
|
||||
<div className="studio-canvas-ghost__hint">网页端首版只做本地预览,正式生成仍会继续走服务端队列。</div>
|
||||
<div className="studio-canvas-ghost__hint">点击或拖拽上传;支持图片 (PNG/JPG/WEBP) 和音频 (MP3/WAV/M4A)</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -842,6 +842,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const skipInitialCloneAutoSaveRef = useRef(true);
|
||||
const skipNextCloneAutoSaveRef = useRef(false);
|
||||
const [activeTool, setActiveTool] = useState<ProductKitToolKey>("clone");
|
||||
useEffect(() => { setPreviewZoom(1); }, [activeTool]);
|
||||
const [setImages, setSetImages] = useState<CloneImageItem[]>([]);
|
||||
const [productSetPlatform, setProductSetPlatform] = useState(platformOptions[0]);
|
||||
const [productSetMarket, setProductSetMarket] = useState(marketOptions[0]);
|
||||
@@ -882,6 +883,30 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const [videoOutfitRefFile, setVideoOutfitRefFile] = useState<File | null>(null);
|
||||
const [isCloneSettingsCollapsed, setIsCloneSettingsCollapsed] = useState(false);
|
||||
const [previewZoom, setPreviewZoom] = useState(1);
|
||||
|
||||
const handlePreviewWheel = (event: React.WheelEvent<HTMLElement>) => {
|
||||
if (!event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cursorX = event.clientX - rect.left;
|
||||
const cursorY = event.clientY - rect.top;
|
||||
const zoomDelta = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
|
||||
const nextZoom = Math.min(2, Math.max(0.25, previewZoom * zoomDelta));
|
||||
if (nextZoom === previewZoom) return;
|
||||
|
||||
const contentX = (cursorX + container.scrollLeft) / previewZoom;
|
||||
const contentY = (cursorY + container.scrollTop) / previewZoom;
|
||||
|
||||
setPreviewZoom(nextZoom);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollLeft = contentX * nextZoom - cursorX;
|
||||
container.scrollTop = contentY * nextZoom - cursorY;
|
||||
});
|
||||
};
|
||||
|
||||
const [requirement, setRequirement] = useState("");
|
||||
const [requirementImageMentionQuery, setRequirementImageMentionQuery] = useState<string | null>(null);
|
||||
const [cloneSettingName, setCloneSettingName] = useState("新建创作");
|
||||
@@ -963,6 +988,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const selectedProductSetOutput =
|
||||
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
|
||||
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
|
||||
const cloneRequirementPlaceholder =
|
||||
cloneOutput === "model"
|
||||
? "建议包含以下信息:产品名称、核心卖点、期望场景、模特外貌描写(如小麦色皮肤、齐刘海、眼角有泪痣)、具体参数"
|
||||
: "建议包含以下信息,产品名称,核心卖点,期望场景,具体参数";
|
||||
const productSetPreviewReady = productSetStatus === "done";
|
||||
const cloneSetTotal = useMemo(
|
||||
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
|
||||
@@ -1909,7 +1938,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
age: cloneModelAge,
|
||||
ethnicity: cloneModelEthnicity,
|
||||
body: cloneModelBody,
|
||||
appearance: cloneModelAppearance,
|
||||
scenes: selectedCloneModelScenes,
|
||||
customScene: cloneModelCustomScene,
|
||||
}
|
||||
@@ -2200,7 +2228,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
cloneModelSelects={cloneModelSelects}
|
||||
openCloneModelSelect={openCloneModelSelect}
|
||||
cloneModelSelectDropUp={cloneModelSelectDropUp}
|
||||
cloneModelAppearance={cloneModelAppearance}
|
||||
cloneVideoQuality={cloneVideoQuality}
|
||||
cloneVideoQualityOptions={cloneVideoQualityOptions}
|
||||
cloneVideoDuration={cloneVideoDuration}
|
||||
@@ -2232,7 +2259,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
setCloneModelCustomScene={setCloneModelCustomScene}
|
||||
setOpenCloneModelSelect={setOpenCloneModelSelect}
|
||||
setCloneModelSelectDropUp={setCloneModelSelectDropUp}
|
||||
setCloneModelAppearance={setCloneModelAppearance}
|
||||
setCloneVideoQuality={setCloneVideoQuality}
|
||||
setCloneVideoDuration={setCloneVideoDuration}
|
||||
clampCloneVideoDuration={clampCloneVideoDuration}
|
||||
@@ -2332,7 +2358,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const setPreview = (
|
||||
<main className="product-clone-preview product-clone-preview--set" aria-label="AI商品套图预览">
|
||||
<main className="product-clone-preview product-clone-preview--set" aria-label="AI商品套图预览" onWheel={handlePreviewWheel}>
|
||||
<div className="product-clone-preview__headline">
|
||||
<h1>预览</h1>
|
||||
<p>
|
||||
@@ -2400,7 +2426,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const clonePreview = (
|
||||
<main className="product-clone-preview clone-ai-preview" aria-label="电商AI作图预览">
|
||||
<main className="product-clone-preview clone-ai-preview" aria-label="电商AI作图预览" onWheel={handlePreviewWheel}>
|
||||
<header className="clone-ai-preview-header">
|
||||
<strong>预览</strong>
|
||||
<span>
|
||||
@@ -2595,7 +2621,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
if (event.key === "Escape") setRequirementImageMentionQuery(null);
|
||||
}}
|
||||
maxLength={500}
|
||||
placeholder="建议包含以下信息,产品名称,核心卖点,期望场景,具体参数"
|
||||
placeholder={cloneRequirementPlaceholder}
|
||||
/>
|
||||
{requirementImageMentionQuery !== null && ecommerceMentionImages.length ? (
|
||||
<ImageMentionMenu images={ecommerceMentionImages} query={requirementImageMentionQuery} onSelect={insertRequirementImageMention} />
|
||||
@@ -2610,7 +2636,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const detailPreview = (
|
||||
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览">
|
||||
<main className="product-clone-preview product-clone-preview--detail" aria-label="A+详情预览" onWheel={handlePreviewWheel}>
|
||||
<div className="product-clone-preview__headline">
|
||||
<h1>A+/详情页</h1>
|
||||
<p>
|
||||
@@ -2647,7 +2673,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
);
|
||||
|
||||
const tryOnPreview = (
|
||||
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿戴预览">
|
||||
<main className="product-clone-preview product-clone-preview--try-on" aria-label="服饰穿戴预览" onWheel={handlePreviewWheel}>
|
||||
<div className="product-clone-preview__headline">
|
||||
<h1>AI服饰穿戴</h1>
|
||||
<p>上传服装图,定制专属模特,即刻生成多种场景不同姿势套图。</p>
|
||||
|
||||
@@ -100,7 +100,6 @@ interface EcommerceClonePanelProps {
|
||||
cloneModelSelects: CloneModelSelectItem[];
|
||||
openCloneModelSelect: CloneModelSelectKey | null;
|
||||
cloneModelSelectDropUp: boolean;
|
||||
cloneModelAppearance: string;
|
||||
cloneVideoQuality: CloneVideoQualityKey;
|
||||
cloneVideoQualityOptions: CloneVideoQualityOption[];
|
||||
cloneVideoDuration: number;
|
||||
@@ -132,7 +131,6 @@ interface EcommerceClonePanelProps {
|
||||
setCloneModelCustomScene: (value: string) => void;
|
||||
setOpenCloneModelSelect: (value: CloneModelSelectKey | null) => void;
|
||||
setCloneModelSelectDropUp: (value: boolean) => void;
|
||||
setCloneModelAppearance: (value: string) => void;
|
||||
setCloneVideoQuality: (value: CloneVideoQualityKey) => void;
|
||||
setCloneVideoDuration: (value: number) => void;
|
||||
clampCloneVideoDuration: (value: number) => number;
|
||||
@@ -172,7 +170,6 @@ export default function EcommerceClonePanel({
|
||||
cloneModelSelects,
|
||||
openCloneModelSelect,
|
||||
cloneModelSelectDropUp,
|
||||
cloneModelAppearance,
|
||||
cloneVideoQuality,
|
||||
cloneVideoQualityOptions,
|
||||
cloneVideoDuration,
|
||||
@@ -204,7 +201,6 @@ export default function EcommerceClonePanel({
|
||||
setCloneModelCustomScene,
|
||||
setOpenCloneModelSelect,
|
||||
setCloneModelSelectDropUp,
|
||||
setCloneModelAppearance,
|
||||
setCloneVideoQuality,
|
||||
setCloneVideoDuration,
|
||||
clampCloneVideoDuration,
|
||||
@@ -668,14 +664,6 @@ export default function EcommerceClonePanel({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="clone-ai-model-textarea">
|
||||
<strong>外貌细节(可选)</strong>
|
||||
<textarea
|
||||
value={cloneModelAppearance}
|
||||
onChange={(event) => setCloneModelAppearance(event.target.value)}
|
||||
placeholder="例如:小麦色皮肤、齐刘海、眼角有泪痣..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -758,7 +746,7 @@ export default function EcommerceClonePanel({
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitVideoRef.current?.click()}>
|
||||
{videoOutfitVideoUrl ? "重新选择视频" : "选择视频文件"}
|
||||
{videoOutfitVideoUrl ? "重新上传视频" : "点击上传视频"}
|
||||
</button>
|
||||
{videoOutfitVideoUrl ? <span className="clone-ai-video-outfit-info">已选择视频</span> : null}
|
||||
</div>
|
||||
@@ -774,7 +762,7 @@ export default function EcommerceClonePanel({
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<button type="button" className="clone-ai-video-outfit-upload-btn" onClick={() => videoOutfitRefRef.current?.click()}>
|
||||
{videoOutfitRefUrl ? "重新选择参考图" : "选择参考图"}
|
||||
{videoOutfitRefUrl ? "重新上传参考图" : "点击上传参考图"}
|
||||
</button>
|
||||
{videoOutfitRefUrl ? <span className="clone-ai-video-outfit-info">已选择参考图</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
|
||||
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
||||
|
||||
interface EcommerceDetailPanelProps {
|
||||
@@ -59,6 +59,31 @@ export default function EcommerceDetailPanel({
|
||||
handleDetailGenerate,
|
||||
onCancelGenerate,
|
||||
}: EcommerceDetailPanelProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files.length) {
|
||||
handleDetailUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="product-clone-panel__scroll">
|
||||
@@ -67,7 +92,14 @@ export default function EcommerceDetailPanel({
|
||||
商品原图
|
||||
<QuestionCircleOutlined />
|
||||
</h2>
|
||||
<button type="button" className="product-clone-upload-zone product-detail-upload" onClick={() => detailInputRef.current?.click()}>
|
||||
<button
|
||||
type="button"
|
||||
className={`product-clone-upload-zone product-detail-upload${isDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => detailInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<strong>
|
||||
<CloudUploadOutlined />
|
||||
上传图片
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CloudUploadOutlined, LoadingOutlined, QuestionCircleOutlined } from "@ant-design/icons";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import { useState, type ChangeEvent, type DragEvent, type RefObject } from "react";
|
||||
import { EcommerceProgressBar } from "../EcommerceProgressBar";
|
||||
|
||||
interface EcommerceTryOnPanelProps {
|
||||
@@ -73,12 +73,44 @@ export default function EcommerceTryOnPanel({
|
||||
handleTryOnGenerate,
|
||||
onCancelGenerate,
|
||||
}: EcommerceTryOnPanelProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.types.includes("Files")) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (e.dataTransfer.files.length) {
|
||||
handleGarmentUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="product-clone-panel__scroll">
|
||||
<section className="product-clone-field">
|
||||
<h2>服装图片</h2>
|
||||
<button type="button" className="product-clone-upload-zone product-try-on-upload" onClick={() => garmentInputRef.current?.click()}>
|
||||
<button
|
||||
type="button"
|
||||
className={`product-clone-upload-zone product-try-on-upload${isDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => garmentInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<strong>
|
||||
<CloudUploadOutlined />
|
||||
服装图片
|
||||
|
||||
@@ -35,6 +35,7 @@ const {
|
||||
|
||||
interface HomePageProps {
|
||||
onOpenGenerate: () => void;
|
||||
onStartOnboarding?: () => void;
|
||||
onOpenCanvas?: () => void;
|
||||
onOpenEcommerce: () => void;
|
||||
onOpenScriptReview?: () => void;
|
||||
@@ -476,7 +477,7 @@ function EcommerceFeatureShowcase() {
|
||||
);
|
||||
}
|
||||
|
||||
function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
|
||||
function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcommerce, onOpenScriptReview, onOpenTokenMonitor, onSelectView, onOpenImageTool }: HomePageProps) {
|
||||
const [splashDismissed, setSplashDismissed] = useState(() => sessionStorage.getItem("omniai:splash-seen") === "1");
|
||||
const [activeSlideIndex, setActiveSlideIndex] = useState(0);
|
||||
const [carouselMotion, setCarouselMotion] = useState<HomeCarouselMotion | null>(null);
|
||||
@@ -628,7 +629,7 @@ function HomePage({ onOpenGenerate, onOpenCanvas, onOpenEcommerce, onOpenScriptR
|
||||
</div>
|
||||
|
||||
<div className="omni-home__actions" aria-label="首页入口">
|
||||
<button type="button" className="omni-home__entry" onClick={onOpenGenerate}>
|
||||
<button type="button" className="omni-home__entry" onClick={onStartOnboarding || onOpenGenerate}>
|
||||
<PlusOutlined />
|
||||
<span>
|
||||
快速生成
|
||||
|
||||
@@ -947,19 +947,22 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`image-workbench-empty image-workbench-empty--button${isInpaintDragging ? " is-dragging" : ""}`}
|
||||
<div
|
||||
className={`studio-canvas-ghost${isInpaintDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => inpaintFileInputRef.current?.click()}
|
||||
onDragOver={handleInpaintDragOver}
|
||||
onDragLeave={handleInpaintDragLeave}
|
||||
onDrop={handleInpaintDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inpaintFileInputRef.current?.click(); }}
|
||||
>
|
||||
{isInpaintDragging ? <span className="image-workbench-upload-drop-overlay" style={{ borderRadius: "var(--radius-sm)" }}><span>释放文件以上传</span></span> : null}
|
||||
<FileImageOutlined />
|
||||
<strong>拖拽或选择图片</strong>
|
||||
<span>支持 PNG / JPG / WebP</span>
|
||||
</button>
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<FileImageOutlined />
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">点击或拖拽上传图片</div>
|
||||
<div className="studio-canvas-ghost__hint">支持 PNG / JPG / WebP,上传后使用画笔标注需要重绘的区域</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1389,12 +1392,21 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
|
||||
<img src={referenceImage} alt="参考图预览" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="studio-canvas-ghost">
|
||||
<div
|
||||
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||
>
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">上传参考图后在此预览</div>
|
||||
<div className="studio-canvas-ghost__hint">生成结果也会显示在这里</div>
|
||||
<div className="studio-canvas-ghost__hint">点击或拖拽上传 (PNG / JPG / WebP),生成结果也会显示在这里</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ShareAltOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import "../../styles/pages/profile.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
@@ -228,6 +228,28 @@ function ProfilePage({
|
||||
const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "访";
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isBannerDragging, setIsBannerDragging] = useState(false);
|
||||
const [isAvatarDragging, setIsAvatarDragging] = useState(false);
|
||||
|
||||
const handleBannerDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsBannerDragging(true); };
|
||||
const handleBannerDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsBannerDragging(false); };
|
||||
const handleBannerDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsBannerDragging(false);
|
||||
if (e.dataTransfer.files.length) {
|
||||
handleBannerUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer.types.includes("Files")) setIsAvatarDragging(true); };
|
||||
const handleAvatarDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsAvatarDragging(false); };
|
||||
const handleAvatarDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsAvatarDragging(false);
|
||||
if (e.dataTransfer.files.length) {
|
||||
handleAvatarUpload({ target: { files: e.dataTransfer.files } } as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
};
|
||||
|
||||
const [mode, setMode] = useState<WebAuthMode>("login");
|
||||
const [authTab, setAuthTab] = useState<AuthTab>("password");
|
||||
@@ -1047,8 +1069,11 @@ function ProfilePage({
|
||||
<input ref={avatarInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleAvatarUpload(event)} />
|
||||
<input ref={bannerInputRef} type="file" accept="image/*" hidden onChange={(event) => void handleBannerUpload(event)} />
|
||||
<header
|
||||
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
|
||||
className={`profile-page__banner${bannerUrl ? " has-image" : ""}${isBannerDragging ? " is-dragging" : ""}`}
|
||||
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
|
||||
onDragOver={handleBannerDragOver}
|
||||
onDragLeave={handleBannerDragLeave}
|
||||
onDrop={handleBannerDrop}
|
||||
>
|
||||
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景">
|
||||
<CameraOutlined />
|
||||
@@ -1060,13 +1085,21 @@ function ProfilePage({
|
||||
<div className="profile-page__body">
|
||||
<aside className="profile-page__sidebar">
|
||||
<div className="profile-page__sidebar-head">
|
||||
<div className="profile-page__avatar-ring">
|
||||
<div className={`profile-page__avatar-ring${isAvatarDragging ? " is-dragging" : ""}`}>
|
||||
{avatarUrl ? (
|
||||
<img className="profile-page__avatar" src={avatarUrl} alt="" />
|
||||
) : (
|
||||
<span className="profile-page__avatar">{avatarLabel}</span>
|
||||
)}
|
||||
<button type="button" className="profile-page__avatar-edit" onClick={() => avatarInputRef.current?.click()} aria-label="更换头像">
|
||||
<button
|
||||
type="button"
|
||||
className="profile-page__avatar-edit"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
onDragOver={handleAvatarDragOver}
|
||||
onDragLeave={handleAvatarDragLeave}
|
||||
onDrop={handleAvatarDrop}
|
||||
aria-label="更换头像"
|
||||
>
|
||||
<CameraOutlined />
|
||||
</button>
|
||||
<span className="profile-page__avatar-badge">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CheckCircleOutlined, FlagOutlined, MailOutlined, PhoneOutlined } from "
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { publicConfigClient, type WebPublicConfig } from "../../api/publicConfigClient";
|
||||
import { reportClient, type ReportInput } from "../../api/reportClient";
|
||||
import "../../styles/pages/compliance.css";
|
||||
|
||||
type SubmitState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
|
||||
@@ -601,11 +601,20 @@ function ResolutionUpscalePage({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="studio-canvas-ghost">
|
||||
<div
|
||||
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||
>
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<ThunderboltOutlined />
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">{mode === "image" ? "拖拽或选择图片" : "拖拽或选择视频"}</div>
|
||||
<div className="studio-canvas-ghost__title">{mode === "image" ? "点击或拖拽上传图片" : "点击或拖拽上传视频"}</div>
|
||||
<div className="studio-canvas-ghost__hint">{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -679,14 +679,23 @@ function ScriptTokensPage() {
|
||||
</div>
|
||||
) : !result && (
|
||||
<div className="script-eval-v5-input-section">
|
||||
<div className="script-eval-v5-illustration" aria-label="上传剧本并开始评测">
|
||||
<div className={`script-eval-v5-illustration${isDragging ? " is-dragging" : ""}`} aria-label="上传剧本并开始评测">
|
||||
<div
|
||||
className="script-eval-v5-illustration-hit"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onKeyDown={uploadKeyDown}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{isDragging && (
|
||||
<div className="script-eval-v5-upload-drop-overlay">
|
||||
<UploadOutlined />
|
||||
<span>释放文件以上传</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="script-eval-v5-upload-card-icon">
|
||||
<ShellIcon name="file-text" />
|
||||
</div>
|
||||
|
||||
@@ -447,15 +447,19 @@ function SubtitleRemovalPage({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="studio-canvas-ghost"
|
||||
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||
>
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">拖拽或选择视频</div>
|
||||
<div className="studio-canvas-ghost__title">点击或拖拽上传视频</div>
|
||||
<div className="studio-canvas-ghost__hint">仅支持 MP4,最大 1GB,最高 1080P</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ScissorOutlined,
|
||||
SwapOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import "../../styles/pages/more-tools.css";
|
||||
import "../../styles/pages/image-workbench.css";
|
||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||
@@ -48,6 +48,7 @@ function WatermarkRemovalPage({
|
||||
const [status, setStatus] = useState("上传含水印的图片,点击开始去水印");
|
||||
const [activeTaskId, setActiveTaskId] = useState("");
|
||||
const [taskProgress, setTaskProgress] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isSavingAsset, setIsSavingAsset] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
@@ -124,6 +125,10 @@ function WatermarkRemovalPage({
|
||||
setStatus(`已导入 ${file.name}`);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); };
|
||||
const handleDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
|
||||
const handleCanvasDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); handleFileDrop(e); };
|
||||
|
||||
const handleImportUrl = () => {
|
||||
const normalizedUrl = sourceUrl.trim();
|
||||
if (!/^https?:\/\//i.test(normalizedUrl)) {
|
||||
@@ -403,17 +408,22 @@ function WatermarkRemovalPage({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="image-workbench-empty image-workbench-empty--button"
|
||||
<div
|
||||
className={`studio-canvas-ghost${isDragging ? " is-dragging" : ""}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleFileDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleCanvasDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
<strong>拖拽或选择含水印图片</strong>
|
||||
<span>支持 PNG / JPG / WebP</span>
|
||||
</button>
|
||||
<div className="studio-canvas-ghost__icon">
|
||||
<DeleteOutlined />
|
||||
</div>
|
||||
<div className="studio-canvas-ghost__title">点击或拖拽上传图片</div>
|
||||
<div className="studio-canvas-ghost__hint">支持 PNG / JPG / WebP,上传含水印图片后点击"开始去水印"</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -67,7 +67,6 @@ import { downloadResultAsset } from "./workbenchDownload";
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
formatTextTokenUsage,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../../utils/taskLifecycle";
|
||||
@@ -79,10 +78,12 @@ import {
|
||||
import { isViduModel } from "../../utils/viduRouting";
|
||||
import { isPixverseModel } from "../../utils/pixverseRouting";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||
import {
|
||||
getImageQualityOptions,
|
||||
getImageQualityOptionsForContext,
|
||||
getDefaultImageQuality,
|
||||
getDefaultImageQualityForContext,
|
||||
getVideoQualityOptions,
|
||||
getDefaultVideoQuality,
|
||||
getVideoQualityLabel,
|
||||
@@ -192,12 +193,15 @@ import {
|
||||
PromptPreviewLayer,
|
||||
} from "./WorkbenchPromptPreview";
|
||||
import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips";
|
||||
import OnboardingTour, { type TourPhaseId } from "../../components/OnboardingTour";
|
||||
|
||||
export type { WorkbenchResultActionPayload } from "./workbenchConstants";
|
||||
|
||||
interface WorkbenchPageProps {
|
||||
isAuthenticated: boolean;
|
||||
session: WebUserSession | null;
|
||||
onboarding?: boolean;
|
||||
onEndOnboarding?: () => void;
|
||||
onRequireLogin: (input: CreatePreviewTaskInput) => void;
|
||||
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
|
||||
onRefreshUsage?: () => void;
|
||||
@@ -221,9 +225,17 @@ const MODE_ICONS: Record<WorkbenchMode, ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
};
|
||||
|
||||
function formatCreditValue(value: number): string {
|
||||
if (!Number.isFinite(value)) return "-";
|
||||
if (value >= 100) return Math.round(value).toLocaleString("zh-CN");
|
||||
return Number(value.toFixed(2)).toString();
|
||||
}
|
||||
|
||||
function WorkbenchPage({
|
||||
isAuthenticated,
|
||||
session,
|
||||
onboarding,
|
||||
onEndOnboarding,
|
||||
onRequireLogin,
|
||||
onOpenResultInCanvas,
|
||||
onRefreshUsage,
|
||||
@@ -257,7 +269,41 @@ function WorkbenchPage({
|
||||
const renderedMessageIdsRef = useRef<string[]>([]);
|
||||
const hasHandledInitialMessagesRef = useRef(false);
|
||||
|
||||
const [activeMode, setActiveMode] = useState<WorkbenchMode>("video");
|
||||
// Onboarding signal — init from prop or localStorage
|
||||
const [effectiveOnboarding, setEffectiveOnboarding] = useState(
|
||||
() => onboarding || (() => { try { return window.localStorage.getItem("omniai:onboarding") === "1"; } catch { return false; } })(),
|
||||
);
|
||||
|
||||
// Track whether onboarding prop was ever true, to avoid overwriting localStorage-initiated true
|
||||
const obWasActiveRef = useRef(onboarding);
|
||||
useEffect(() => {
|
||||
if (onboarding) {
|
||||
obWasActiveRef.current = true;
|
||||
setEffectiveOnboarding(true);
|
||||
} else if (obWasActiveRef.current) {
|
||||
// Only deactivate when prop transitions true→false (user dismissed)
|
||||
setEffectiveOnboarding(false);
|
||||
obWasActiveRef.current = false;
|
||||
}
|
||||
// If prop was never true, don't touch effectiveOnboarding (preserves localStorage init)
|
||||
}, [onboarding]);
|
||||
|
||||
// Poll localStorage as a fallback (handles cases where prop isn't propagated)
|
||||
useEffect(() => {
|
||||
if (effectiveOnboarding) return;
|
||||
const check = () => {
|
||||
try {
|
||||
if (window.localStorage.getItem("omniai:onboarding") === "1") {
|
||||
setEffectiveOnboarding(true);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
check();
|
||||
const interval = setInterval(check, 200);
|
||||
return () => clearInterval(interval);
|
||||
}, [effectiveOnboarding]);
|
||||
|
||||
const [activeMode, setActiveMode] = useState<WorkbenchMode>(() => effectiveOnboarding ? "chat" : "video");
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => (resetToken ? [] : readStoredMessages()));
|
||||
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
|
||||
@@ -287,6 +333,34 @@ function WorkbenchPage({
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
|
||||
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
|
||||
// ── Onboarding tour state ──────────────────────────────
|
||||
const [tourPhase, setTourPhase] = useState<TourPhaseId>("chat");
|
||||
const [tourStep, setTourStep] = useState(0);
|
||||
|
||||
// Sync activeMode with tour phase and keep home view during onboarding
|
||||
useEffect(() => {
|
||||
if (!effectiveOnboarding) return;
|
||||
// Reset tour state for repeat runs
|
||||
setTourPhase("chat");
|
||||
setTourStep(0);
|
||||
// Force "今天想生成什么?" home view — prevent conversation auto-select
|
||||
skipConversationAutoSelectRef.current = true;
|
||||
setWorkspaceStarted(false);
|
||||
setActiveConversationId(null);
|
||||
activeConversationIdRef.current = null;
|
||||
persistActiveConversationId(null);
|
||||
messagesRef.current = [];
|
||||
setMessages([]);
|
||||
}, [effectiveOnboarding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveOnboarding) {
|
||||
if (tourPhase === "chat") setActiveMode("chat");
|
||||
else if (tourPhase === "image") setActiveMode("image");
|
||||
else if (tourPhase === "video") setActiveMode("video");
|
||||
}
|
||||
}, [effectiveOnboarding, tourPhase]);
|
||||
// ───────────────────────────────────────────────────────
|
||||
const [, setGenerationProgress] = useState(0);
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
|
||||
@@ -420,6 +494,7 @@ function WorkbenchPage({
|
||||
const toolTheme = MODE_META[activeMode];
|
||||
const workbenchAccent = "#00ff88";
|
||||
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
|
||||
const hasActivatedWorkspace = !effectiveOnboarding && (workspaceStarted || isGenerating || hasConversationRecords);
|
||||
const referenceCount = referenceItems.length;
|
||||
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
|
||||
const activeModelValue =
|
||||
@@ -464,11 +539,72 @@ function WorkbenchPage({
|
||||
setSidebarCollapsed(!hasSidebarRecords);
|
||||
}, [hasSidebarRecords]);
|
||||
|
||||
const imageQualityOptions = useMemo(() => getImageQualityOptions(imageModel), [imageModel]);
|
||||
const hasImageReferences = activeMode === "image" && referenceItems.some((item) => item.kind === "image");
|
||||
const isImageGridMode = activeMode === "image" && imageGridMode !== "single";
|
||||
const imageQualityContext = useMemo(
|
||||
() => ({
|
||||
hasReferenceImages: hasImageReferences,
|
||||
isGridMode: isImageGridMode,
|
||||
}),
|
||||
[hasImageReferences, isImageGridMode],
|
||||
);
|
||||
const imageQualityOptions = useMemo(
|
||||
() => getImageQualityOptionsForContext(imageModel, imageQualityContext),
|
||||
[imageModel, imageQualityContext],
|
||||
);
|
||||
const imageGridModeOptions = useMemo(
|
||||
() =>
|
||||
String(imageModel || "").toLowerCase().startsWith("wan2.7-")
|
||||
? GRID_MODE_OPTIONS.filter((option) => option.value !== "grid-25")
|
||||
: GRID_MODE_OPTIONS,
|
||||
[imageModel],
|
||||
);
|
||||
const videoQualityOptions = getVideoQualityOptions(videoModel);
|
||||
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
|
||||
|
||||
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
|
||||
const billingEstimate = useMemo(() => {
|
||||
if (activeMode === "image") {
|
||||
return {
|
||||
label: "预计 20 积分",
|
||||
title: `图片生成按任务计费:${activeModel},${imageSettingsSummary},预计 20 积分`,
|
||||
};
|
||||
}
|
||||
if (activeMode === "video") {
|
||||
try {
|
||||
const durationSeconds = Math.max(1, Math.ceil(Number(videoDuration) || 1));
|
||||
const credits = calculateEnterpriseVideoCredits({
|
||||
model: activeModelValue,
|
||||
resolution: videoQuality,
|
||||
durationSeconds,
|
||||
muted: false,
|
||||
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
|
||||
});
|
||||
return {
|
||||
label: `预计 ${formatCreditValue(credits)} 积分`,
|
||||
title: `${activeModel},${videoQualityLabel},${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
label: "计费以提交后为准",
|
||||
title: "当前模型的预估计费暂不可用,实际扣费以服务端结算为准",
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: "按 Token 结算",
|
||||
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
|
||||
};
|
||||
}, [
|
||||
activeMode,
|
||||
activeModel,
|
||||
activeModelValue,
|
||||
imageSettingsSummary,
|
||||
referenceItems,
|
||||
videoDuration,
|
||||
videoQuality,
|
||||
videoQualityLabel,
|
||||
]);
|
||||
const composerPlaceholder =
|
||||
referenceItems.length > 0 ? `${toolTheme.placeholder},可输入 @ 引用参考内容` : toolTheme.placeholder;
|
||||
const dropdownDirection = hasActivatedWorkspace ? "up" : "down";
|
||||
@@ -1158,9 +1294,15 @@ function WorkbenchPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageQualityOptions.some((option) => option.value === imageQuality)) {
|
||||
setImageQuality(getDefaultImageQuality(imageModel));
|
||||
setImageQuality(getDefaultImageQualityForContext(imageModel, imageQualityContext));
|
||||
}
|
||||
}, [imageModel, imageQuality, imageQualityOptions]);
|
||||
}, [imageModel, imageQuality, imageQualityContext, imageQualityOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageGridModeOptions.some((option) => option.value === imageGridMode)) {
|
||||
setImageGridMode("single");
|
||||
}
|
||||
}, [imageGridMode, imageGridModeOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeMode !== "video" || videoFrameMode !== "start-end" || referenceItems.length <= 2) return;
|
||||
@@ -1498,7 +1640,41 @@ function WorkbenchPage({
|
||||
setToolbarMenuId((current) => (current === menuId ? null : menuId));
|
||||
};
|
||||
|
||||
// ── Onboarding tour helpers ────────────────────────────
|
||||
const obTarget = (map: Partial<Record<TourPhaseId, string>>): string | undefined =>
|
||||
effectiveOnboarding ? map[tourPhase] : undefined;
|
||||
|
||||
const handleTourNext = useCallback((_phase: TourPhaseId, stepIndex: number) => {
|
||||
setTourStep(stepIndex);
|
||||
}, []);
|
||||
|
||||
const handleTourSkip = useCallback((phase: TourPhaseId) => {
|
||||
const next: Record<TourPhaseId, TourPhaseId> = { chat: "image", image: "video", video: "video" };
|
||||
const nextPhase = next[phase];
|
||||
if (nextPhase === phase) {
|
||||
onEndOnboarding?.();
|
||||
} else {
|
||||
setTourPhase(nextPhase);
|
||||
setTourStep(0);
|
||||
if (nextPhase === "image") setActiveMode("image");
|
||||
else if (nextPhase === "video") setActiveMode("video");
|
||||
}
|
||||
}, [onEndOnboarding, setActiveMode]);
|
||||
|
||||
const handleTourDone = useCallback(() => {
|
||||
setEffectiveOnboarding(false);
|
||||
onEndOnboarding?.();
|
||||
}, [onEndOnboarding]);
|
||||
|
||||
// Advance tour phase when user switches mode during onboarding
|
||||
const handleModeChange = (mode: WorkbenchMode) => {
|
||||
if (effectiveOnboarding) {
|
||||
// Advance tour phase when switching to the next mode
|
||||
if (tourPhase === "chat" && mode === "image") { setTourPhase("image"); setTourStep(0); }
|
||||
else if (tourPhase === "image" && mode === "video") { setTourPhase("video"); setTourStep(0); }
|
||||
// Block switching to other modes during guided tour
|
||||
else if (mode !== tourPhase) return;
|
||||
}
|
||||
setActiveMode(mode);
|
||||
setToolbarMenuId(null);
|
||||
setReferencePreviewOpen(false);
|
||||
@@ -2585,7 +2761,15 @@ function WorkbenchPage({
|
||||
}
|
||||
};
|
||||
|
||||
const sendDisabled = !inputValue.trim() || (activeMode !== "chat" && getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3);
|
||||
const activeGenerationCount = getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id));
|
||||
const generationLimitReached = activeMode !== "chat" && activeGenerationCount >= 3;
|
||||
const promptIsEmpty = !inputValue.trim();
|
||||
const sendDisabled = promptIsEmpty || generationLimitReached;
|
||||
const sendButtonTitle = promptIsEmpty
|
||||
? "输入内容后可发送"
|
||||
: generationLimitReached
|
||||
? `当前已有 ${activeGenerationCount} 个任务进行中,请等待任一任务完成`
|
||||
: billingEstimate.title;
|
||||
|
||||
const suggestedPrompts = [
|
||||
{ text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode },
|
||||
@@ -2713,6 +2897,7 @@ function WorkbenchPage({
|
||||
className="wb-composer__ref-upload"
|
||||
onClick={handleReferenceUploadClick}
|
||||
disabled={disabled}
|
||||
data-onboarding={obTarget({ chat: "onboarding-chat-upload", image: "onboarding-image-upload", video: "onboarding-video-upload" })}
|
||||
aria-label={`上传${referenceUploadLabel}`}
|
||||
aria-expanded={referenceItems.length > 0 ? referencePreviewOpen : undefined}
|
||||
aria-controls={referenceItems.length > 0 ? "workbench-reference-stack" : undefined}
|
||||
@@ -2772,6 +2957,7 @@ function WorkbenchPage({
|
||||
const renderComposerToolbar = (disabled = false, showStop = false) => (
|
||||
<div className="wb-composer__toolbar">
|
||||
<div className="wb-composer__toolbar-left">
|
||||
<span data-onboarding={obTarget({ chat: "onboarding-mode-selector", image: "onboarding-mode-selector" })}>
|
||||
<SelectChip
|
||||
chipId="studio-mode"
|
||||
value={activeMode}
|
||||
@@ -2784,8 +2970,10 @@ function WorkbenchPage({
|
||||
ariaLabel="工作台模式"
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
{activeMode === "chat" && (
|
||||
<>
|
||||
<span data-onboarding={obTarget({ chat: "onboarding-chat-model" })}>
|
||||
<SelectChip
|
||||
chipId="chat-model"
|
||||
value={chatModel}
|
||||
@@ -2798,6 +2986,8 @@ function WorkbenchPage({
|
||||
ariaLabel="对话模型"
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ chat: "onboarding-chat-speed" })}>
|
||||
<SelectChip
|
||||
chipId="chat-speed"
|
||||
value={thinkingSpeed}
|
||||
@@ -2810,6 +3000,8 @@ function WorkbenchPage({
|
||||
ariaLabel="思考速度"
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ chat: "onboarding-chat-depth" })}>
|
||||
<SelectChip
|
||||
chipId="chat-depth"
|
||||
value={thinkingDepth}
|
||||
@@ -2822,10 +3014,12 @@ function WorkbenchPage({
|
||||
ariaLabel="思考深度"
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{activeMode === "image" && (
|
||||
<>
|
||||
<span data-onboarding={obTarget({ image: "onboarding-image-model" })}>
|
||||
<SelectChip
|
||||
chipId="image-model"
|
||||
value={imageModel}
|
||||
@@ -2837,6 +3031,8 @@ function WorkbenchPage({
|
||||
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ image: "onboarding-image-settings" })}>
|
||||
<CompoundSelectChip
|
||||
chipId="image-settings"
|
||||
summary={imageSettingsSummary}
|
||||
@@ -2846,11 +3042,13 @@ function WorkbenchPage({
|
||||
onToggle={() => toggleToolbarMenu("image-settings")}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
{GRID_SUPPORTED_MODELS.has(imageModel) && (
|
||||
<span data-onboarding={obTarget({ image: "onboarding-image-grid" })}>
|
||||
<SelectChip
|
||||
chipId="image-grid-mode"
|
||||
value={imageGridMode}
|
||||
options={GRID_MODE_OPTIONS}
|
||||
options={imageGridModeOptions}
|
||||
disabled={disabled}
|
||||
isOpen={toolbarMenuId === "image-grid-mode"}
|
||||
onToggle={() => toggleToolbarMenu("image-grid-mode")}
|
||||
@@ -2858,11 +3056,13 @@ function WorkbenchPage({
|
||||
onChange={setImageGridMode}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activeMode === "video" && (
|
||||
<>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-model" })}>
|
||||
<SelectChip
|
||||
chipId="video-model"
|
||||
value={videoModel}
|
||||
@@ -2874,6 +3074,8 @@ function WorkbenchPage({
|
||||
onChange={setVideoModel}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-frame" })}>
|
||||
<SelectChip
|
||||
chipId="video-mode"
|
||||
value={videoFrameMode}
|
||||
@@ -2885,6 +3087,8 @@ function WorkbenchPage({
|
||||
onChange={setVideoFrameMode}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-ratio" })}>
|
||||
<CompoundSelectChip
|
||||
chipId="video-ratio"
|
||||
summary={videoRatio}
|
||||
@@ -2894,6 +3098,8 @@ function WorkbenchPage({
|
||||
onToggle={() => toggleToolbarMenu("video-ratio")}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-duration" })}>
|
||||
<InlineOptionChip
|
||||
chipId="video-duration"
|
||||
value={videoDuration}
|
||||
@@ -2906,6 +3112,8 @@ function WorkbenchPage({
|
||||
onChange={setVideoDuration}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
<span data-onboarding={obTarget({ video: "onboarding-video-quality" })}>
|
||||
<InlineOptionChip
|
||||
chipId="video-quality"
|
||||
value={videoQuality}
|
||||
@@ -2918,14 +3126,21 @@ function WorkbenchPage({
|
||||
onChange={setVideoQuality}
|
||||
direction={dropdownDirection}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="wb-composer__toolbar-right">
|
||||
<span className="wb-composer__billing-estimate" title={billingEstimate.title}>
|
||||
{billingEstimate.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`wb-composer__send-primary${isGenerating ? " is-loading" : ""}`}
|
||||
disabled={sendDisabled || isGenerating}
|
||||
title={isGenerating ? "任务处理中" : sendButtonTitle}
|
||||
aria-label={isGenerating ? "任务处理中" : sendButtonTitle}
|
||||
data-onboarding={obTarget({ video: "onboarding-video-generate" })}
|
||||
onClick={() => {
|
||||
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
|
||||
mode: activeMode,
|
||||
@@ -3084,6 +3299,7 @@ function WorkbenchPage({
|
||||
className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`}
|
||||
placeholder={composerPlaceholder}
|
||||
value={inputValue}
|
||||
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
|
||||
onChange={handlePromptChange}
|
||||
onSelect={handlePromptSelectionChange}
|
||||
onKeyUp={handlePromptSelectionChange}
|
||||
@@ -3149,6 +3365,14 @@ function WorkbenchPage({
|
||||
{renderMessagePreviewOverlay()}
|
||||
{renderPromptCaseOverlay()}
|
||||
{renderDeleteDialog()}
|
||||
<OnboardingTour
|
||||
active={Boolean(effectiveOnboarding)}
|
||||
phase={tourPhase}
|
||||
stepIndex={tourStep}
|
||||
onNext={handleTourNext}
|
||||
onSkip={handleTourSkip}
|
||||
onDone={handleTourDone}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3231,11 +3455,6 @@ 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}
|
||||
@@ -3275,6 +3494,7 @@ function WorkbenchPage({
|
||||
placeholder={composerPlaceholder}
|
||||
value={inputValue}
|
||||
disabled={false}
|
||||
data-onboarding={obTarget({ chat: "onboarding-chat-input", image: "onboarding-image-input" })}
|
||||
onChange={handlePromptChange}
|
||||
onSelect={handlePromptSelectionChange}
|
||||
onKeyUp={handlePromptSelectionChange}
|
||||
@@ -3322,6 +3542,15 @@ function WorkbenchPage({
|
||||
{showRechargeModal && RechargeModal ? (
|
||||
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
|
||||
) : null}
|
||||
|
||||
<OnboardingTour
|
||||
active={Boolean(effectiveOnboarding)}
|
||||
phase={tourPhase}
|
||||
stepIndex={tourStep}
|
||||
onNext={handleTourNext}
|
||||
onSkip={handleTourSkip}
|
||||
onDone={handleTourDone}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,14 +149,14 @@ export const CHAT_MODEL_OPTIONS: WorkbenchOption[] = [
|
||||
|
||||
export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [
|
||||
{ value: "default", label: "默认" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "ultra", label: "急速" },
|
||||
{ value: "high", label: "思考速度:高" },
|
||||
{ value: "ultra", label: "思考速度:急速" },
|
||||
];
|
||||
|
||||
export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [
|
||||
{ value: "default", label: "默认" },
|
||||
{ value: "strong", label: "强" },
|
||||
{ value: "extreme", label: "极限" },
|
||||
{ value: "strong", label: "推理深度:强" },
|
||||
{ value: "extreme", label: "推理深度:极限" },
|
||||
];
|
||||
|
||||
export const CHAT_NATURAL_SYSTEM_PROMPT = [
|
||||
@@ -231,13 +231,13 @@ export const MODE_OPTIONS: WorkbenchOption[] = (Object.keys(MODE_META) as Workbe
|
||||
}));
|
||||
|
||||
export const IMAGE_MODEL_OPTIONS: WorkbenchOption[] = [
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro 4K" },
|
||||
{ value: "wan2.7-image-pro", label: "wan 2.7 Pro" },
|
||||
{ value: "wan2.7-image", label: "wan 2.7" },
|
||||
{ value: "gpt-image-2", label: "GPT-Image-2" },
|
||||
{ value: "gpt-image-2-vip", label: "GPT-Image-2 VIP" },
|
||||
{ value: "nano-banana-pro", label: "Nano Banana Pro" },
|
||||
{ value: "nano-banana-2", label: "Nano Banana 2" },
|
||||
{ value: "nano-banana-fast", label: "Nano Banana" },
|
||||
{ value: "gpt-image-2", label: "omni-GPT" },
|
||||
{ value: "gpt-image-2-vip", label: "omni-GPT VIP" },
|
||||
{ value: "nano-banana-pro", label: "omni-水果 Pro" },
|
||||
{ value: "nano-banana-2", label: "omni-水果 2" },
|
||||
{ value: "nano-banana-fast", label: "omni-水果" },
|
||||
];
|
||||
|
||||
export const VIDEO_MODEL_OPTIONS: WorkbenchOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ ...option }));
|
||||
|
||||
Reference in New Issue
Block a user