feat: 多页面UI打磨 — 设置面板、状态反馈与样式升级
Web Quality / verify (pull_request) Has been cancelled

本次更新对多个功能页面进行了系统性的 UI/UX 打磨,统一了交互模式并补充了缺失的状态反馈。

## 新增功能
- WorkbenchPage: 图片提示词案例区域新增加载骨架屏、错误回退、空数据三种状态展示
- CharacterMixPage: 新增左侧设置面板(驱动提示词、图像检测开关、水印开关),支持清除已上传的人物图/参考视频
- DigitalHumanPage: 新增左侧设置面板(提示词输入、去水印/保留原声开关),支持清除已上传的人像/音频,增加取消生成按钮
- ImageWorkbenchPage / ResolutionUpscalePage: 新增参数设置面板和资产清除交互
- MorePage: 新增页面入口

## UI 优化
- 统一 Toggle 开关组件: 所有设置页面采用一致的 .studio-toggle 交互模式
- 资产清除: 各上传区域新增清除按钮,含二次确认和提示反馈
- 生成按钮: 统一为带图标的 .studio-generate-btn,增加 disabled/loading 状态
- ConversationSidebar / ProjectSidebar: 侧边栏交互细节优化

## 样式升级
- image-workbench.css: 大幅扩展样式 (+1900 行),覆盖设置面板、上传区、结果展示等
- workbench.css: 新增 666 行样式,含骨架屏动画、案例卡片网格、状态占位等
- subtitle-removal.css: 补充设置面板样式
This commit is contained in:
2026-06-10 17:54:45 +08:00
parent 77ffd01a50
commit a6626beb32
15 changed files with 2949 additions and 251 deletions
+119 -74
View File
@@ -281,6 +281,94 @@ function CharacterMixPage({
}
};
const clearCharacterAsset = () => {
if (characterPreview) URL.revokeObjectURL(characterPreview);
setCharacterFile("");
setCharacterPreview("");
setCharacterDataUrl("");
setFaceHint(null);
if (characterInputRef.current) characterInputRef.current.value = "";
setNotice("已移除人物图");
};
const clearReferenceVideo = () => {
if (videoPreview) URL.revokeObjectURL(videoPreview);
setVideoFile("");
setVideoPreview("");
setVideoDataUrl("");
if (videoInputRef.current) videoInputRef.current.value = "";
setNotice("已移除参考视频");
};
const characterMixSettingsPanel = (
<div className="studio-panel__section character-mix-settings-panel">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div className="character-mix-prompt-field">
<div className="studio-label"></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="保持角色原有服装,动作流畅自然"
rows={3}
maxLength={1000}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${checkImage ? " is-on" : ""}`} onClick={() => setCheckImage(!checkImage)}>
<span className="studio-toggle__thumb" />
</button>
</div>
{checkImage && characterPreview && faceHint && (
<div className={`character-mix-face-hint character-mix-face-hint--${faceHint}`}>
{faceHint === "analyzing" ? (
<>
<InfoCircleOutlined />
<span>...</span>
</>
) : (
<>
<CheckCircleOutlined />
<span></span>
</>
)}
</div>
)}
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !characterDataUrl || !videoDataUrl}>
{isCreating ? <LoadingOutlined /> : <PlayCircleOutlined />}
{isCreating ? "生成中..." : "开始迁移"}
</button>
{resultUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
);
return (
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
<header className="image-workbench-topbar">
@@ -339,8 +427,10 @@ function CharacterMixPage({
<StudioToolLayout
noTop
noRight
leftPanel={
<div
className="character-mix-source-panel"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -389,6 +479,20 @@ function CharacterMixPage({
<strong>{characterFile || "上传人物图"}</strong>
<small></small>
</span>
{characterPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除人物图"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearCharacterAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
</div>
</div>
@@ -427,9 +531,24 @@ function CharacterMixPage({
<strong>{videoFile || "上传参考视频"}</strong>
<small>MP4 / MOV / AVI</small>
</span>
{videoPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除参考视频"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearReferenceVideo();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
</div>
</div>
{characterMixSettingsPanel}
</div>
}
canvas={
@@ -480,80 +599,6 @@ function CharacterMixPage({
</div>
)
}
rightPanel={
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div style={{ marginBottom: 10 }}>
<div className="studio-label" style={{ fontSize: 11, color: "var(--fg-muted, #999)", marginBottom: 4 }}></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="保持角色原有服装,动作流畅自然"
rows={3}
maxLength={1000}
style={{
width: "100%", resize: "vertical", background: "var(--bg-elevated, #1a1a1a)",
border: "1px solid var(--border-subtle, #333)", borderRadius: 6,
padding: "6px 8px", fontSize: 12, color: "var(--fg-body, #eee)",
fontFamily: "inherit", outline: "none", boxSizing: "border-box",
}}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${checkImage ? " is-on" : ""}`} onClick={() => setCheckImage(!checkImage)}>
<span className="studio-toggle__thumb" />
</button>
</div>
{checkImage && characterPreview && faceHint && (
<div className={`character-mix-face-hint character-mix-face-hint--${faceHint}`}>
{faceHint === "analyzing" ? (
<>
<InfoCircleOutlined />
<span>...</span>
</>
) : (
<>
<CheckCircleOutlined />
<span></span>
</>
)}
</div>
)}
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !characterDataUrl || !videoDataUrl}>
{isCreating ? <LoadingOutlined /> : <PlayCircleOutlined />}
{isCreating ? "生成中..." : "开始迁移"}
</button>
{resultUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
}
statusBar={
<>
<span className="studio-status-bar__badge studio-status-bar__badge--idle"></span>
+120 -77
View File
@@ -206,6 +206,24 @@ function DigitalHumanPage({
}
};
const clearImageAsset = () => {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImageName("");
setImageFile(null);
setImagePreview("");
if (imageInputRef.current) imageInputRef.current.value = "";
setNotice("已移除参考人像");
};
const clearAudioAsset = () => {
if (audioPreview) URL.revokeObjectURL(audioPreview);
setAudioName("");
setAudioFile(null);
setAudioPreview("");
if (audioInputRef.current) audioInputRef.current.value = "";
setNotice("已移除音频源");
};
const handleDownloadResult = async () => {
if (!resultVideoUrl || isDownloadingResult) return;
setIsDownloadingResult(true);
@@ -418,6 +436,76 @@ function DigitalHumanPage({
}
};
const digitalHumanSettingsPanel = (
<div className="studio-panel__section digital-human-settings-panel">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div className="digital-human-prompt-field">
<div className="studio-label"></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="例如:自然微笑,边说边轻微点头"
rows={3}
maxLength={2500}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${!watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${keepOriginalAudio ? " is-on" : ""}`} onClick={() => setKeepOriginalAudio(!keepOriginalAudio)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
<PlayCircleOutlined />
{isCreating ? "生成中..." : "开始生成"}
</button>
{isCreating && (
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
<CloseCircleOutlined />
</button>
)}
{resultVideoUrl && (
<button type="button" className="studio-generate-btn" onClick={() => {
setResultVideoUrl("");
setActiveTaskId("");
setTaskProgress(0);
setNotice("已清空工作区");
}}>
</button>
)}
{resultVideoUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
);
return (
<section className="image-workbench-page digital-human-page" aria-label="数字人">
<header className="image-workbench-topbar">
@@ -476,8 +564,10 @@ function DigitalHumanPage({
<StudioToolLayout
noTop
noRight
leftPanel={
<div
className="digital-human-source-panel"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -523,6 +613,20 @@ function DigitalHumanPage({
<strong>{imageName || "上传参考图"}</strong>
<small>PNG / JPG / WEBP</small>
</span>
{imagePreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除参考人像"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearImageAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
</div>
</div>
@@ -558,10 +662,26 @@ function DigitalHumanPage({
<strong>{audioName || "上传音频"}</strong>
<small>MP3 / WAV / M4A 5 </small>
</span>
{audioPreview ? (
<button
type="button"
className="studio-upload-slot__remove"
aria-label="移除音频源"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clearAudioAsset();
}}
>
<DeleteOutlined />
</button>
) : null}
</label>
{audioPreview ? <audio src={audioPreview} controls className="studio-audio-preview" /> : null}
</div>
</div>
{digitalHumanSettingsPanel}
</div>
}
canvas={
@@ -596,83 +716,6 @@ function DigitalHumanPage({
</div>
)
}
rightPanel={
<>
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
</div>
<div className="studio-panel__section-body">
<div style={{ marginBottom: 8 }}>
<div className="studio-label" style={{ fontSize: 11, color: "var(--fg-muted, #999)", marginBottom: 4 }}></div>
<textarea
value={promptInput}
onChange={(e) => setPromptInput(e.target.value)}
placeholder="例如:自然微笑,边说边轻微点头"
rows={3}
maxLength={2500}
style={{
width: "100%", resize: "vertical", background: "var(--bg-elevated, #1a1a1a)",
border: "1px solid var(--border-subtle, #333)", borderRadius: 6,
padding: "6px 8px", fontSize: 12, color: "var(--fg-body, #eee)",
fontFamily: "inherit", outline: "none", boxSizing: "border-box",
}}
/>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${!watermark ? " is-on" : ""}`} onClick={() => setWatermark(!watermark)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<div className="studio-toggle-row">
<div className="studio-toggle-row__copy">
<span className="studio-toggle-row__title"></span>
<span className="studio-toggle-row__desc"></span>
</div>
<button type="button" className={`studio-toggle${keepOriginalAudio ? " is-on" : ""}`} onClick={() => setKeepOriginalAudio(!keepOriginalAudio)}>
<span className="studio-toggle__thumb" />
</button>
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
<PlayCircleOutlined />
{isCreating ? "生成中..." : "开始生成"}
</button>
{isCreating && (
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
<CloseCircleOutlined />
</button>
)}
{resultVideoUrl && (
<button type="button" className="studio-generate-btn" onClick={() => {
setResultVideoUrl("");
setActiveTaskId("");
setTaskProgress(0);
setNotice("已清空工作区");
}}>
</button>
)}
{resultVideoUrl && (
<div className="studio-result-actions">
<button type="button" onClick={() => void handleDownloadResult()} disabled={isDownloadingResult}>
<DownloadOutlined />
{isDownloadingResult ? "保存中" : "保存本地"}
</button>
<button type="button" onClick={() => void handleAddResultToAssets()} disabled={isSavingResultAsset}>
<InboxOutlined />
{isSavingResultAsset ? "加入中" : "加入资产库"}
</button>
</div>
)}
</div>
</div>
</>
}
statusBar={
<>
<span className="studio-status-bar__badge studio-status-bar__badge--running"></span>
+3 -17
View File
@@ -13,13 +13,11 @@ import "../../styles/pages/home.css";
import WelcomeSplash from "./WelcomeSplash";
import ToolboxSection from "./ToolboxSection";
import ScriptReviewShowcase from "./ScriptReviewShowcase";
import ModelGenerationShowcase from "./ModelGenerationShowcase";
const [heroImage1, heroImage2, heroImage3] = ossAssets.home.heroSlides;
const {
ecommerce: featureEcommerceImage,
script: featureScriptImage,
token: featureTokenImage,
} = ossAssets.home.features;
interface HomePageProps {
@@ -42,16 +40,6 @@ const HOME_CAROUSEL_IMAGES = [
];
const HOME_FEATURES = [
{
key: "model",
eyebrow: "AI Generation",
title: "模型生成",
description: "通过AI模型生成文本、图片、视频,三种模式覆盖全内容类型,Agent对话式交互智能产出。",
imageUrl: featureTokenImage,
actionLabel: "开始生成",
icon: <ThunderboltOutlined />,
stats: ["文本生成", "图片生成", "视频生成"],
},
{
key: "ecommerce",
eyebrow: "AI Commerce",
@@ -646,7 +634,7 @@ function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcomm
<main className="omni-home__feature-pages" aria-label="OmniAI 功能介绍">
{HOME_FEATURES.map((feature, index) => (
<section key={feature.key} className={`omni-home__feature-page is-${feature.key}${index % 2 ? " is-alt" : ""}`}>
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
{feature.key !== "script" && feature.key !== "ecommerce" ? (
<div className="omni-home__feature-copy">
<span>
{feature.icon}
@@ -660,18 +648,16 @@ function HomePage({ onOpenGenerate, onStartOnboarding, onOpenCanvas, onOpenEcomm
</button>
</div>
) : null}
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce"}>
<div className="omni-home__feature-visual" aria-hidden={feature.key !== "script" && feature.key !== "ecommerce"}>
{feature.key === "script" ? (
<ScriptReviewShowcase />
) : feature.key === "model" ? (
<ModelGenerationShowcase />
) : feature.key === "ecommerce" ? (
<EcommerceFeatureShowcase />
) : (
<img src={feature.imageUrl} alt="" />
)}
</div>
{feature.key !== "script" && feature.key !== "model" && feature.key !== "ecommerce" ? (
{feature.key !== "script" && feature.key !== "ecommerce" ? (
<div className="omni-home__feature-stats" aria-hidden="true">
{feature.stats.map((item) => (
<span key={item}>{item}</span>
@@ -609,6 +609,20 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
);
const handleRemoveWorkbenchResult = (index: number) => {
setResultImages((current) => {
const next = current.filter((_, imageIndex) => imageIndex !== index);
if (next.length) {
saveToolTaskState("imagewb", { taskId: taskIdRef.current || "", resultUrl: next[0], status: "完成", progress: 100 });
setStatus(`已移除生成图,剩余 ${next.length}`);
} else {
clearToolTaskState("imagewb");
setStatus("已移除生成图");
}
return next;
});
};
const handleGenerate = async () => {
if (!referenceImages.length && !prompt.trim()) {
setStatus("请先上传参考图或输入提示词");
@@ -797,7 +811,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label="删除局部重绘素材"
onClick={handleRemoveInpaintImage}
>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -830,7 +844,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</label>
</section>
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-inpaint-params-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
@@ -842,7 +856,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
</section>
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-inpaint-prompt-card">
<h3></h3>
<textarea
className="image-workbench-prompt"
@@ -967,7 +981,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
) : activeTool === "camera" ? (
<main className="image-workbench-layout image-workbench-layout--camera">
<aside className="image-workbench-panel image-workbench-panel--left">
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-camera-material">
<div className="image-workbench-section-title">
<h3></h3>
<span>{cameraImage ? "已导入" : "待上传"}</span>
@@ -997,7 +1011,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label="删除镜头参考图"
onClick={handleRemoveCameraImage}
>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -1248,7 +1262,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label={`删除参考图 ${index + 1}`}
onClick={() => handleRemoveReferenceImage(index)}
>
×
<DeleteOutlined />
</button>
</div>
))}
@@ -1282,7 +1296,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
aria-label="删除参考图"
onClick={() => handleRemoveReferenceImage(0)}
>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -1300,7 +1314,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
</section>
<section className="image-workbench-control-card">
<section className="image-workbench-control-card image-workbench-output-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
@@ -1365,6 +1379,14 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<div className="image-workbench-result-grid">
{resultImages.map((url, i) => (
<div key={url} className="image-workbench-result-card">
<button
type="button"
className="image-workbench-result-remove"
aria-label={`移除生成结果 ${i + 1}`}
onClick={() => handleRemoveWorkbenchResult(i)}
>
<DeleteOutlined />
</button>
<a href={url} target="_blank" rel="noopener noreferrer" className="image-workbench-result-item">
<img src={url} alt={`生成结果 ${i + 1}`} />
</a>
+2
View File
@@ -40,12 +40,14 @@ interface MoreTool {
}
const toolPreviewImages: Record<string, string> = {
workbench: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/image-workbench-20260609132455.png",
inpaint: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%B1%80%E9%83%A8%E9%87%8D%E7%BB%98.PNG",
camera: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E9%95%9C%E5%A4%B4%E5%AE%9E%E9%AA%8C%E5%AE%A4.PNG",
upscale: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%88%86%E8%BE%A8%E7%8E%87%E6%8F%90%E5%8D%87.PNG",
watermarkRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%8E%BB%E6%B0%B4%E5%8D%B0.PNG",
dialogGenerator: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E4%BA%A4%E4%BA%92%E5%BC%8F%E5%AF%B9%E8%AF%9D%E6%A1%86%E7%94%9F%E6%88%90%E5%99%A8.PNG",
subtitleRemoval: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E5%AD%97%E5%B9%95%E5%8E%BB%E9%99%A4.PNG",
digitalHuman: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/static/toolbox/digital-human-20260609132455.png",
characterMix: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E8%A7%92%E8%89%B2%E8%BF%81%E7%A7%BB.PNG",
avatarConsole: "https://stringtest.oss-cn-hangzhou.aliyuncs.com/toolbox/images/%E6%95%B0%E5%AD%97%E4%BA%BA%E6%8E%A7%E5%88%B6%E5%8F%B0.PNG",
};
@@ -439,7 +439,7 @@ function ResolutionUpscalePage({
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -459,13 +459,20 @@ function ResolutionUpscalePage({
<section className="image-workbench-control-card">
<h3></h3>
{mode === "image" ? (
<label className="image-workbench-select">
<span></span>
<select value={imageScale} onChange={(event) => setImageScale(event.target.value as ImageScale)}>
<option value="2x">2x</option>
<option value="4x">4x</option>
</select>
</label>
<div className="resolution-upscale-scale-options" role="radiogroup" aria-label="放大倍数">
{(["2x", "4x"] as ImageScale[]).map((scale) => (
<button
key={scale}
type="button"
className={`resolution-upscale-scale-option${imageScale === scale ? " is-active" : ""}`}
aria-pressed={imageScale === scale}
onClick={() => setImageScale(scale)}
>
<strong>{scale}</strong>
<span>{scale === "2x" ? "日常清晰增强" : "高倍细节修复"}</span>
</button>
))}
</div>
) : (
<>
<div className="resolution-upscale-style-chips">
@@ -548,6 +555,7 @@ function ResolutionUpscalePage({
resultLabel={resultPreview ? resultSizeText : "等待结果"}
sourceAlt="原图预览"
resultAlt="超分结果预览"
aspectRatio={sourceDimensions ? `${sourceDimensions.width} / ${sourceDimensions.height}` : undefined}
onSourceLoad={(width, height) => setSourceDimensions({ width, height })}
/>
{resultPreview && (
@@ -360,7 +360,7 @@ function SubtitleRemovalPage({
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -339,7 +339,7 @@ function WatermarkRemovalPage({
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
<DeleteOutlined />
</button>
) : null}
</div>
@@ -21,8 +21,22 @@ interface ConversationSidebarProps {
}
function formatRelativeTime(dateStr: string): string {
const relativeMatch = dateStr.trim().match(/^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|mo|month|months|y|yr|year|years)\s+ago$/i);
if (relativeMatch) {
const value = Number(relativeMatch[1]);
const unit = relativeMatch[2].toLowerCase();
if (unit.startsWith("s")) return "刚刚";
if (unit === "m" || unit.startsWith("min")) return `${value} 分钟前`;
if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) return `${value} 小时前`;
if (unit === "d" || unit.startsWith("day")) return `${value} 天前`;
if (unit === "w" || unit.startsWith("week")) return `${value} 周前`;
if (unit === "mo" || unit.startsWith("month")) return `${value} 个月前`;
if (unit === "y" || unit.startsWith("yr") || unit.startsWith("year")) return `${value} 年前`;
}
const now = Date.now();
const then = new Date(dateStr).getTime();
if (!Number.isFinite(then)) return dateStr;
const diff = now - then;
if (diff < 60_000) return "刚刚";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
+18 -5
View File
@@ -25,13 +25,26 @@ interface ProjectSidebarProps {
}
function formatRelativeTime(dateStr: string): string {
const relativeMatch = dateStr.trim().match(/^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|mo|month|months|y|yr|year|years)\s+ago$/i);
if (relativeMatch) {
const value = Number(relativeMatch[1]);
const unit = relativeMatch[2].toLowerCase();
if (unit.startsWith("s")) return "刚刚";
if (unit === "m" || unit.startsWith("min")) return `${value} 分钟前`;
if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) return `${value} 小时前`;
if (unit === "d" || unit.startsWith("day")) return `${value} 天前`;
if (unit === "w" || unit.startsWith("week")) return `${value} 周前`;
if (unit === "mo" || unit.startsWith("month")) return `${value} 个月前`;
if (unit === "y" || unit.startsWith("yr") || unit.startsWith("year")) return `${value} 年前`;
}
const then = new Date(dateStr).getTime();
if (!Number.isFinite(then)) return "";
if (!Number.isFinite(then)) return dateStr;
const diff = Date.now() - then;
if (diff < 60_000) return "just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} h ago`;
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} d ago`;
if (diff < 60_000) return "刚刚";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`;
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)} 天前`;
return new Date(dateStr).toLocaleDateString("zh-CN");
}
+46 -24
View File
@@ -278,6 +278,7 @@ function WorkbenchPage({
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
const [promptCaseStatus, setPromptCaseStatus] = useState<"loading" | "ready" | "error">("loading");
const [promptCaseMeasuredRatios, setPromptCaseMeasuredRatios] = useState<Record<string, number>>({});
const [mentionPanelPlacement, setMentionPanelPlacement] = useState<"above" | "below">("above");
const [isGenerating, setIsGenerating] = useState(false);
@@ -718,6 +719,7 @@ function WorkbenchPage({
useEffect(() => {
let cancelled = false;
setPromptCaseStatus("loading");
communityClient
.listApprovedCases({ limit: 100, tag: "生成页面社区", sort: "latest" })
.then((items) => {
@@ -727,10 +729,12 @@ function WorkbenchPage({
.map(communityCaseToPromptCase)
.filter((item): item is PromptCaseViewModel => Boolean(item)),
);
setPromptCaseStatus("ready");
})
.catch(() => {
if (!cancelled) {
setServerPromptCases([]);
setPromptCaseStatus("error");
}
});
@@ -3371,30 +3375,48 @@ function WorkbenchPage({
<div className="wb-showcase__header">
<h2></h2>
</div>
<div className="wb-prompt-cases__grid">
{promptCaseDisplayItems.map((item, index) => {
const measuredRatio = promptCaseMeasuredRatios[item.id];
return (
<button
key={item.id}
type="button"
className={getPromptCaseCardClassName(item, index, measuredRatio)}
onClick={() => setSelectedPromptCase(item)}
>
<img
src={item.imageUrl}
alt={item.title}
loading="lazy"
onLoad={(event) => handlePromptCaseImageLoad(item.id, event)}
/>
<div>
<strong>{item.title}</strong>
<em>{item.author}</em>
</div>
</button>
);
})}
</div>
{promptCaseStatus === "loading" ? (
<div className="wb-prompt-cases__grid wb-prompt-cases__grid--skeleton" aria-label="图片提示词案例加载中">
{Array.from({ length: 8 }, (_, index) => (
<span key={index} className={`wb-prompt-case-skeleton wb-prompt-case-skeleton--${index % 4}`} />
))}
</div>
) : promptCaseStatus === "error" ? (
<div className="wb-prompt-cases__state">
<strong></strong>
<span></span>
</div>
) : promptCaseDisplayItems.length === 0 ? (
<div className="wb-prompt-cases__state">
<strong></strong>
<span></span>
</div>
) : (
<div className="wb-prompt-cases__grid">
{promptCaseDisplayItems.map((item, index) => {
const measuredRatio = promptCaseMeasuredRatios[item.id];
return (
<button
key={item.id}
type="button"
className={getPromptCaseCardClassName(item, index, measuredRatio)}
onClick={() => setSelectedPromptCase(item)}
>
<img
src={item.imageUrl}
alt={item.title}
loading="lazy"
onLoad={(event) => handlePromptCaseImageLoad(item.id, event)}
/>
<div>
<strong>{item.title}</strong>
<em>{item.author}</em>
</div>
</button>
);
})}
</div>
)}
</section>
</div>