接入创作主页画布直达引导

创作工具卡片接入画布生成器直达意图

新建空白画布增加工具栏引导并在生成面板打开后关闭

暂未开放工具复用移动端欢迎弹窗提示

补充主页、项目入口和画布启动意图测试

同步创作主页入口改版文档
This commit is contained in:
2026-06-30 18:13:05 +08:00
parent 97da4f21bb
commit dfe6bafb5e
11 changed files with 506 additions and 25 deletions
@@ -27,7 +27,7 @@
- 社群入口:原地弹出“玩家社区”二维码弹窗,复用现有社区弹层能力,不跳转或切换到“我的 / 用户中心”。
- 营销点:`登录即送100泥点,可以免费制作50个素材`
`开始创作` 与“新建项目”使用同一项目创建链路。用户已登录时调用现有 `createEditorProject`,成功后进入 `/editor/canvas?projectid=xxx`;未登录或登录过期时打开登录弹窗,并在登录后重试创建。
`开始创作` 与“新建项目”使用同一项目创建链路。用户已登录时调用现有 `createEditorProject`,成功后进入 `/editor/canvas?projectid=xxx&guide=toolbar`,画布只消费一次 `guide=toolbar` 并清理 query,用于显示新画布工具栏引导;未登录或登录过期时打开登录弹窗,并在登录后重试创建。
该区是页面第一屏的品牌与主操作区域,不使用外站品牌名。桌面端 `/creation` 继续保留现有平台左侧导航栏和上方栏,创作主页只替换中间内容区。
@@ -45,6 +45,16 @@
8. 游戏功能编码:自然语言驱动游戏生成。
9. 一键导出:支持一键导出为微抖小游戏、Godot、Unity、UE 工程。
交互口径:
- 游戏视觉规范:创建新画布后进入 `/editor/canvas?projectid=xxx&tool=icon-spec`,画布消费 `tool=icon-spec` 后拉起图标规范生成面板。
- 游戏角色:创建新画布后进入 `/editor/canvas?projectid=xxx&tool=character-spec`,画布消费后拉起角色规范生成面板。
- 游戏 UI:创建新画布后进入 `/editor/canvas?projectid=xxx&tool=ui-design`,画布消费后拉起生成 UI 设计图面板。
- 游戏音乐:创建新画布后进入 `/editor/canvas?projectid=xxx&tool=background-music`,画布消费后拉起生成游戏背景音乐面板。
- 游戏场景:创建新画布后进入 `/editor/canvas?projectid=xxx&tool=image`,画布消费后拉起生成图片面板。
- 游戏美宣:创建新画布后进入 `/editor/canvas?projectid=xxx&tool=publication-cover`,画布消费后拉起游戏首图宣发素材面板。
- 游戏特效、游戏功能编码、一键导出:不创建项目,复用移动端欢迎弹窗样式展示 `抱歉 / 功能还在调试中 / 暂未开放`,按钮为 `期待一下`
### 最近项目
仅登录后展示。模块位置在“陶泥儿精选”上方。
@@ -53,7 +63,7 @@
- 标题右侧:`查看全部`,跳转 `/project`
- 左侧第一项:`新建项目`
- 右侧项目卡:按 `updatedAt` 倒序展示最近 4 个项目。
- 项目卡点击进入 `/editor/canvas?projectid=xxx`
- 项目卡点击进入 `/editor/canvas?projectid=xxx`;“新建项目”进入 `/editor/canvas?projectid=xxx&guide=toolbar` 并触发一次新画布工具栏引导
- 项目卡点击必须直接写入最终 `/editor/canvas?projectid=xxx` 历史项,不允许先进入无参数 `/editor/canvas` 再二次跳转,否则浏览器返回需要点两次。
- 项目缩略图复用 `/project` 项目卡的画布中心封面逻辑,避免两套封面算法。
@@ -113,7 +123,7 @@ Tab
- `/creation` 是创作工具主页,不承接正式玩法业务真相。
- 创作入口配置继续从 `/api/creation-entry/config` 读取。
- 最近项目继续使用编辑器项目接口:`listEditorProjects``createEditorProject``/editor/canvas?projectid=xxx`
- 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由。
- 最近项目和项目页打开画布时,浏览器 history 只保留带 `projectid` 的最终画布路由;新建画布引导使用 `guide=toolbar` 一次性 query,主站九宫格直达生成器使用 `tool=<intent>` 一次性 query,画布消费后通过 `history.replaceState` 清理这两个参数
- 项目封面逻辑复用 `/project` 项目卡已有的画布中心缩略图算法。
- `陶泥儿精选` 读取后端公开精选接口返回的全站公开画布生成资源;公开开关以后端 `editor_project_resource.public_showcase_enabled` 为准,前端只做展示过滤、同源同媒体副本兜底过滤和乐观交互回滚。
- `/creation/<play>` 的玩法工作台、草稿、生成页、结果页、发布、运行态和作品架链路保持原状。
@@ -147,6 +157,7 @@ Tab
- 新增 `CreationLandingView`
- 实现首屏主视觉、九大功能区、最近项目区和陶泥儿精选素材瀑布流。
- 九大功能区卡片必须是可点击按钮;已开放卡片复用 `createEditorProject` 和画布现有生成器,不新增入口系统;暂未开放卡片复用移动端欢迎弹窗样式。
- 桌面端复用现有平台壳层,保留上方栏和左侧导航栏。
- 主页整体采用浅色布局,避免外站品牌文案。
- 视觉排版参考陶土质感创作工具工作台:首屏标题居中、主按钮与社区按钮使用大号圆角胶囊尺寸,九大创作工具以 3 列大卡片展示,卡片左侧保留大号陶土感图标区域,右侧展示原有标题与描述;桌面平台一级页的顶部栏和左侧导航统一压平成单层工作台 chrome,避免外框套内框;桌面端页面结构必须先分为“左侧完整栏 / 右侧内容区”,左侧完整栏同时包含顶部品牌 Logo 和下方导航页签,右侧内容区再拆为顶部搜索、钱包、账号栏与下方主屏,不能用顶部栏的第一列假装左侧品牌区;左侧栏采用紧凑但保留图标和文字的导航胶囊,顶部搜索栏、泥点入口和账号入口都保持精简宽度,提升推荐、发现、我的、创作主页和项目页的工具感与精致度;只调整样式与布局,不改文案、搜索、登录、导航、项目创建、社区弹层、最近项目、精选素材 Tab 或数据读取逻辑。
@@ -173,6 +184,7 @@ Tab
- `appPageRoutes.test.ts`:覆盖 `/creation -> creation-home`,并确认现有 `/creation/<play>` 不回归。
- `RpgEntryHomeView.recharge.test.tsx`:桌面显示“创作”“项目”,不显示“草稿”;移动端不显示“创作”和“项目”;我的页不再出现项目快捷入口。
- `CreationLandingView.test.tsx`:覆盖首屏主视觉、九大功能、最近项目、新建入口、查看全部、未登录隐藏最近项目、素材 Tab。
- `ImageCanvasEditorView.test.tsx`:覆盖新画布 `guide=toolbar` 显示工具栏引导并在打开生成器后关闭,以及 `tool=background-music` 等启动意图只消费一次。
- `PlatformEntryFlowShellImpl` 相关测试:点击“创作”进入 `/creation`,点击“项目”进入 `/project`,新建项目进入 `/editor/canvas?projectid=xxx`
- `ProjectGalleryView.test.tsx`:封面抽取后项目封面、重命名、删除和选择模式保持通过。
@@ -194,6 +206,9 @@ git diff --check
- 未登录时最近项目区不展示,点击“开始创作”弹登录。
- 登录后显示最近项目、新建项目、最多 4 个项目和“查看全部”。
- 点击新建项目进入 `/editor/canvas?projectid=xxx`
- 点击“开始创作”或项目页“新建项目”进入新画布后,工具栏上方显示 `工具栏在这里,快试着随便做点什么`,底部工具栏有高亮边框;点击任一生成入口并弹出生成面板后,引导和高亮关闭。
- 点击“创作工具”中的游戏视觉规范、游戏角色、游戏 UI、游戏音乐、游戏场景、游戏美宣,分别创建新画布并直接拉起对应的图标规范、角色规范、UI 设计图、背景音乐、图片、游戏首图面板;URL 中的 `tool` 参数被消费后不保留。
- 点击游戏特效、游戏功能编码、一键导出时弹出 `抱歉 / 功能还在调试中 / 暂未开放`,按钮为 `期待一下`,不创建项目。
- 桌面导航显示“项目”,不显示“草稿”。
- `/project` 头部不展示“返回主站”,右侧只保留选择和新建等项目管理动作。
- 移动视口底部不显示“创作”和“项目”。
@@ -195,10 +195,80 @@ describe('CreationLandingView', () => {
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(onOpenProject).toHaveBeenCalledWith('created-project');
expect(onOpenProject).toHaveBeenCalledWith('created-project', {
guide: true,
});
});
});
it.each([
['游戏视觉规范', 'icon-spec'],
['游戏角色', 'character-spec'],
['游戏UI', 'ui-design'],
['游戏音乐', 'background-music'],
['游戏场景', 'image'],
['游戏美宣', 'publication-cover'],
])(
'creates a new project from %s with its canvas tool intent',
async (label, expectedTool) => {
const user = userEvent.setup();
const onOpenProject = vi.fn();
listEditorProjectsMock.mockResolvedValueOnce([]);
createEditorProjectMock.mockResolvedValueOnce({
projectId: 'visual-spec-project',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-18T10:00:00.000Z',
});
render(
<AuthUiContext.Provider value={createAuthValue()}>
<CreationLandingView
onOpenProject={onOpenProject}
onOpenProjects={vi.fn()}
/>
</AuthUiContext.Provider>,
);
await user.click(
screen.getByRole('button', { name: new RegExp(label, 'u') }),
);
await waitFor(() => {
expect(onOpenProject).toHaveBeenCalledWith('visual-spec-project', {
tool: expectedTool,
});
});
},
);
it('shows the pending feature dialog for unavailable creation tool cards', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce([]);
render(
<AuthUiContext.Provider value={createAuthValue()}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
/>
</AuthUiContext.Provider>,
);
await user.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '抱歉' });
expect(dialog.textContent).toContain('功能还在调试中');
expect(dialog.textContent).toContain('暂未开放');
expect(createEditorProjectMock).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '期待一下' }));
expect(screen.queryByRole('dialog', { name: '抱歉' })).toBeNull();
});
it('opens the community entry from the hero without starting project creation', async () => {
const user = userEvent.setup();
const onOpenCommunity = vi.fn();
@@ -29,15 +29,27 @@ import {
} from './creationShowcaseModel';
type CreationLandingViewProps = {
onOpenProject: (projectId: string) => void;
onOpenProject: (
projectId: string,
options?: { guide?: boolean; tool?: CreationFeatureTool },
) => void;
onOpenProjects: () => void;
onOpenCommunity?: () => void;
};
type CreationFeatureTool =
| 'character-spec'
| 'icon-spec'
| 'ui-design'
| 'background-music'
| 'image'
| 'publication-cover';
type CreationFeatureItem = {
title: string;
description: string;
iconSrc: string;
action: { kind: 'tool'; tool: CreationFeatureTool } | { kind: 'pending' };
};
const CREATION_HOME_ASSETS = {
@@ -51,46 +63,55 @@ const CREATION_FEATURES: CreationFeatureItem[] = [
title: '游戏视觉规范',
description: '轻松约束多类素材视觉一致性',
iconSrc: '/creation-home/feature-visual-spec.png',
action: { kind: 'tool', tool: 'icon-spec' },
},
{
title: '游戏角色',
description: '整套高完成度2D/3D角色素材及动画',
iconSrc: '/creation-home/feature-character.png',
action: { kind: 'tool', tool: 'character-spec' },
},
{
title: '游戏UI',
description: '轻松生成高一致性的UI素材',
iconSrc: '/creation-home/feature-ui.png',
action: { kind: 'tool', tool: 'ui-design' },
},
{
title: '游戏音乐',
description: '轻松制作游戏背景音乐、音效',
iconSrc: '/creation-home/feature-music.png',
action: { kind: 'tool', tool: 'background-music' },
},
{
title: '游戏特效',
description: '低成本生成精品游戏特效',
iconSrc: '/creation-home/feature-effects.png',
action: { kind: 'pending' },
},
{
title: '游戏场景',
description: '轻松生成各类游戏场景素材',
iconSrc: '/creation-home/feature-scene.png',
action: { kind: 'tool', tool: 'image' },
},
{
title: '游戏美宣',
description: '轻松生成游戏上架所需图片和视频',
iconSrc: '/creation-home/feature-marketing.png',
action: { kind: 'tool', tool: 'publication-cover' },
},
{
title: '游戏功能编码',
description: '自然语言驱动游戏生成',
iconSrc: '/creation-home/feature-coding.png',
action: { kind: 'pending' },
},
{
title: '一键导出',
description: '支持导出为微抖小游戏、Godot、Unity、UE工程',
iconSrc: '/creation-home/feature-export.png',
action: { kind: 'pending' },
},
];
@@ -377,6 +398,8 @@ export function CreationLandingView({
const [selectedShowcaseItem, setSelectedShowcaseItem] =
useState<ShowcaseAssetItem | null>(null);
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
const [isPendingFeatureDialogOpen, setIsPendingFeatureDialogOpen] =
useState(false);
const refreshRecentProjects = useCallback(() => {
if (!isAuthenticated) {
@@ -431,18 +454,20 @@ export function CreationLandingView({
refreshShowcaseResources();
}, [refreshShowcaseResources]);
const createProject = useCallback(() => {
const createProject = useCallback((tool?: CreationFeatureTool) => {
if (authUi && !authUi.user) {
authUi.openLoginModal(createProject);
authUi.openLoginModal(() => createProject(tool));
return;
}
setIsCreatingProject(true);
setProjectError(null);
createEditorProject()
.then((project) => onOpenProject(project.projectId))
.then((project) =>
onOpenProject(project.projectId, tool ? { tool } : { guide: true }),
)
.catch((error: unknown) => {
if (isUnauthorizedError(error)) {
authUi?.openLoginModal(createProject);
authUi?.openLoginModal(() => createProject(tool));
return;
}
setProjectError(
@@ -452,6 +477,17 @@ export function CreationLandingView({
.finally(() => setIsCreatingProject(false));
}, [authUi, onOpenProject]);
const handleFeatureClick = useCallback(
(feature: CreationFeatureItem) => {
if (feature.action.kind === 'pending') {
setIsPendingFeatureDialogOpen(true);
return;
}
createProject(feature.action.tool);
},
[createProject],
);
const showcaseItems = useMemo(
() =>
buildCreationShowcaseItems({
@@ -533,7 +569,7 @@ export function CreationLandingView({
size="md"
shape="pill"
className="creation-landing__hero-action creation-landing__hero-action--primary"
onClick={createProject}
onClick={() => createProject()}
disabled={isCreatingProject}
>
<img
@@ -580,7 +616,13 @@ export function CreationLandingView({
</div>
<div className="creation-landing__feature-grid">
{CREATION_FEATURES.map((feature) => (
<article key={feature.title} className="creation-landing__feature">
<button
key={feature.title}
type="button"
className="creation-landing__feature"
onClick={() => handleFeatureClick(feature)}
disabled={isCreatingProject && feature.action.kind === 'tool'}
>
<span className="creation-landing__feature-figure">
<span className="creation-landing__feature-icon">
<img
@@ -597,7 +639,7 @@ export function CreationLandingView({
<h3>{feature.title}</h3>
<p>{feature.description}</p>
</span>
</article>
</button>
))}
</div>
</section>
@@ -625,7 +667,7 @@ export function CreationLandingView({
<button
type="button"
className="creation-landing__project-card creation-landing__project-card--new"
onClick={createProject}
onClick={() => createProject()}
disabled={isCreatingProject}
>
<span>
@@ -713,6 +755,41 @@ export function CreationLandingView({
onSelectIndex={setSelectedShowcaseIndex}
onClose={() => setSelectedShowcaseItem(null)}
/>
<UnifiedModal
open={isPendingFeatureDialogOpen}
title="抱歉"
onClose={() => setIsPendingFeatureDialogOpen(false)}
showHeader={false}
showCloseButton={false}
size="sm"
overlayClassName="platform-mobile-home-welcome-overlay !items-center !p-4"
panelClassName="platform-remap-surface platform-mobile-home-welcome-dialog"
bodyClassName="platform-mobile-home-welcome-dialog__body"
footerClassName="platform-mobile-home-welcome-dialog__footer"
footer={
<PlatformActionButton
onClick={() => setIsPendingFeatureDialogOpen(false)}
size="md"
shape="pill"
fullWidth
className="min-h-11"
>
</PlatformActionButton>
}
>
<img
src="/branding/mobile-home-welcome-taonier-ip.png"
alt=""
className="platform-mobile-home-welcome-dialog__icon"
/>
<h1 className="platform-mobile-home-welcome-dialog__title"></h1>
<p className="platform-mobile-home-welcome-dialog__copy">
<br />
</p>
</UnifiedModal>
</main>
);
}
@@ -23,6 +23,7 @@ type ImageCanvasBottomToolbarViewProps = {
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
effectiveTool: CanvasTool;
highlight?: boolean;
onSwitchTool: (tool: CanvasTool) => void;
onOpenToolOptions: (tool: ToolbarOptionTool) => void;
onCloseToolOptions: (tool: ToolbarOptionTool) => void;
@@ -70,13 +71,19 @@ export function ImageCanvasBottomToolbarView({
musicToolWrapRef,
publicationToolWrapRef,
effectiveTool,
highlight = false,
onSwitchTool,
onOpenToolOptions,
onCloseToolOptions,
}: ImageCanvasBottomToolbarViewProps) {
return (
<div
className="image-canvas-editor__bottom-toolbar"
className={[
'image-canvas-editor__bottom-toolbar',
highlight ? 'image-canvas-editor__bottom-toolbar--guided' : '',
]
.filter(Boolean)
.join(' ')}
role="toolbar"
aria-label="AI画布工具栏"
onPointerDown={(event) => event.stopPropagation()}
@@ -127,6 +127,75 @@ describe('ImageCanvasEditorView', () => {
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
});
it('shows the toolbar guide for a newly created blank project until a generator opens', async () => {
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-guide',
title: '空白项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=editor-project-guide&guide=toolbar',
);
render(<ImageCanvasEditorView />);
expect(
await screen.findByText('工具栏在这里,快试着随便做点什么'),
).toBeTruthy();
const bottomToolbar = screen.getByRole('toolbar', {
name: 'AI画布工具栏',
});
expect(bottomToolbar.className).toContain(
'image-canvas-editor__bottom-toolbar--guided',
);
expect(window.location.search).toBe('?projectid=editor-project-guide');
fireEvent.click(
within(bottomToolbar).getByRole('button', { name: '生成图片' }),
);
expect(await screen.findByRole('dialog', { name: '生成图片' })).toBeTruthy();
await waitFor(() => {
expect(
screen.queryByText('工具栏在这里,快试着随便做点什么'),
).toBeNull();
});
expect(bottomToolbar.className).not.toContain(
'image-canvas-editor__bottom-toolbar--guided',
);
});
it('opens the requested canvas startup tool from the project query once', async () => {
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-music',
title: '音乐项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=editor-project-music&tool=background-music',
);
render(<ImageCanvasEditorView />);
expect(
await screen.findByRole('dialog', { name: '生成游戏背景音乐' }),
).toBeTruthy();
expect(
screen.queryByText('工具栏在这里,快试着随便做点什么'),
).toBeNull();
expect(window.location.search).toBe('?projectid=editor-project-music');
});
it('shows the loaded project title and a topbar entry back to projects', async () => {
render(<ImageCanvasEditorView />);
@@ -51,7 +51,10 @@ import type {
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel';
import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
} from './ImageCanvasGenerationModel';
import {
isCanvasGenerationComposerVisible,
resolveQuickEditFocusViewport,
@@ -90,6 +93,81 @@ const TASK_FOCUS_BOTTOM_INSET = 90;
const TASK_FOCUS_DIALOG_BOTTOM_INSET = 280;
const TASK_FOCUS_PADDING = 18;
type CanvasStartupTool =
| 'character-spec'
| 'icon-spec'
| 'ui-design'
| 'background-music'
| 'image'
| 'publication-cover';
const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'character-spec',
'icon-spec',
'ui-design',
'background-music',
'image',
'publication-cover',
];
function isCanvasStartupTool(value: string | null): value is CanvasStartupTool {
return CANVAS_STARTUP_TOOLS.includes(value as CanvasStartupTool);
}
function readCanvasStartupIntent() {
if (typeof window === 'undefined') {
return { showToolbarGuide: false, tool: null };
}
const params = new URLSearchParams(window.location.search);
const rawTool = params.get('tool');
return {
showToolbarGuide: params.get('guide') === 'toolbar',
tool: isCanvasStartupTool(rawTool) ? rawTool : null,
};
}
function clearCanvasStartupIntent() {
if (typeof window === 'undefined') {
return;
}
const params = new URLSearchParams(window.location.search);
params.delete('tool');
params.delete('guide');
const search = params.toString();
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`,
);
}
function openCanvasStartupTool(
tool: CanvasStartupTool,
generationSurface: ReturnType<typeof useImageCanvasGenerationSurface>,
) {
if (tool === 'character-spec') {
generationSurface.openSpecDialog('character');
return;
}
if (tool === 'icon-spec') {
generationSurface.openSpecDialog('ui');
return;
}
if (tool === 'ui-design') {
generationSurface.openUiDesignGenerationDialog();
return;
}
if (tool === 'background-music') {
generationSurface.openBackgroundMusicGenerationDialog();
return;
}
if (tool === 'publication-cover') {
generationSurface.openPublicationGenerationDialog('publication-cover-image');
return;
}
generationSurface.openGenerateDialog();
}
function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) {
const objectKey = layer.objectKey?.trim();
if (objectKey) {
@@ -227,8 +305,10 @@ export function ImageCanvasEditorView() {
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const startupIntentConsumedRef = useRef(false);
const selectedLayerIdRef = useRef<string | null>(null);
const showRechargeEntry = shouldShowRechargeEntry();
const [isToolbarGuideVisible, setIsToolbarGuideVisible] = useState(false);
useEffect(() => {
let isMounted = true;
@@ -900,8 +980,12 @@ export function ImageCanvasEditorView() {
setViewport,
],
);
const { projectId, appendCanvasLayersWithResources, applyProjectSnapshot } =
useImageCanvasProjectPersistence({
const {
projectId,
isProjectReady,
appendCanvasLayersWithResources,
applyProjectSnapshot,
} = useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
@@ -1110,6 +1194,35 @@ export function ImageCanvasEditorView() {
setQuickEditPanelRef.current = setQuickEditPanel;
appendUiAssetExtractionReferencesRef.current =
appendUiAssetExtractionReferences;
useEffect(() => {
if (!isProjectReady || startupIntentConsumedRef.current) {
return;
}
const startupIntent = readCanvasStartupIntent();
if (!startupIntent.showToolbarGuide && !startupIntent.tool) {
startupIntentConsumedRef.current = true;
return;
}
startupIntentConsumedRef.current = true;
clearCanvasStartupIntent();
if (startupIntent.tool) {
setIsToolbarGuideVisible(false);
openCanvasStartupTool(startupIntent.tool, generationSurface);
return;
}
if (
startupIntent.showToolbarGuide &&
layersRef.current.length === 0 &&
canvasGenerationDialogsRef.current.length === 0
) {
setIsToolbarGuideVisible(true);
}
}, [generationSurface, isProjectReady]);
useEffect(() => {
if (isToolbarGuideVisible && isCanvasGenerationDialog(generateDialog)) {
setIsToolbarGuideVisible(false);
}
}, [generateDialog, isToolbarGuideVisible]);
const clearGenerationPanelsAfterBlur = useCallback(() => {
setQuickEditPanel(null);
setCropExpandPanel(null);
@@ -1735,6 +1848,7 @@ export function ImageCanvasEditorView() {
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
isToolbarGuideVisible,
taskListRefreshKey: generationSurface.taskListRefreshKey,
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
generateDialog,
@@ -55,6 +55,7 @@ export type ImageCanvasStageViewProps = {
hoveredLayerId: string | null;
canvasMarquee: CanvasMarqueeState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
isToolbarGuideVisible?: boolean;
taskListRefreshKey: number;
isTaskSidebarOpen: boolean;
generateDialog: GenerateDialogState | null;
@@ -194,6 +195,7 @@ export function ImageCanvasStageView({
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
isToolbarGuideVisible = false,
taskListRefreshKey,
isTaskSidebarOpen,
generateDialog,
@@ -444,11 +446,27 @@ export function ImageCanvasStageView({
onFocusExternalTask={onFocusExternalTask}
/>
{isToolbarGuideVisible ? (
<div
className="image-canvas-editor__toolbar-guide"
role="status"
aria-live="polite"
>
<img
src="/branding/mobile-home-welcome-taonier-ip.png"
alt=""
className="image-canvas-editor__toolbar-guide-ip"
/>
<span>便</span>
</div>
) : null}
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool={effectiveTool}
highlight={isToolbarGuideVisible}
onSwitchTool={onSwitchTool}
onOpenToolOptions={onOpenToolOptions}
onCloseToolOptions={onCloseToolOptions}
@@ -3761,11 +3761,24 @@ export function PlatformEntryFlowShellImpl({
setIsCreationCommunityOpen(true);
}, []);
const openEditorProject = useCallback((projectId: string) => {
setSelectionStage('image-editor', {
path: `/editor/canvas?projectid=${encodeURIComponent(projectId)}`,
});
}, [setSelectionStage]);
const openEditorProject = useCallback(
(
projectId: string,
options?: { guide?: boolean; tool?: string },
) => {
const params = new URLSearchParams({ projectid: projectId });
if (options?.tool) {
params.set('tool', options.tool);
}
if (options?.guide) {
params.set('guide', 'toolbar');
}
setSelectionStage('image-editor', {
path: `/editor/canvas?${params.toString()}`,
});
},
[setSelectionStage],
);
const openCreationTypeModal = useCallback(() => {
if (!prepareCreationLaunch()) {
@@ -431,6 +431,8 @@ describe('ProjectGalleryView', () => {
await user.click(newProjectButton);
expect(createEditorProjectMock).toHaveBeenCalledTimes(1);
expect(onOpenProject).toHaveBeenCalledWith('editor-project-new');
expect(onOpenProject).toHaveBeenCalledWith('editor-project-new', {
guide: true,
});
});
});
@@ -37,7 +37,7 @@ import {
} from './ProjectCanvasCover';
type ProjectGalleryViewProps = {
onOpenProject: (projectId: string) => void;
onOpenProject: (projectId: string, options?: { guide?: boolean }) => void;
};
type RenameDraft = {
@@ -171,7 +171,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
}
setErrorMessage(null);
createEditorProject()
.then((project) => onOpenProject(project.projectId))
.then((project) => onOpenProject(project.projectId, { guide: true }))
.catch((error: unknown) => {
setErrorMessage(
error instanceof Error ? error.message : '创建项目失败',
+96
View File
@@ -3576,6 +3576,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.creation-landing__feature {
appearance: none;
display: grid;
grid-template-columns: minmax(5.6rem, 36%) minmax(0, 1fr);
gap: 1rem;
@@ -3588,9 +3589,33 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
radial-gradient(circle at 21% 42%, rgba(222, 130, 66, 0.12), transparent 9rem),
linear-gradient(135deg, rgba(255, 254, 251, 0.92), rgba(255, 247, 238, 0.82));
padding: 1.05rem 1.3rem;
color: inherit;
font: inherit;
text-align: left;
box-shadow:
0 18px 36px rgba(124, 72, 35, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
cursor: pointer;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.creation-landing__feature:hover,
.creation-landing__feature:focus-visible {
border-color: rgba(209, 103, 54, 0.88);
box-shadow:
0 22px 42px rgba(124, 72, 35, 0.12),
0 0 0 3px rgba(239, 130, 61, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.78);
transform: translateY(-1px);
}
.creation-landing__feature:disabled {
cursor: progress;
opacity: 0.68;
transform: none;
}
.creation-landing__feature-figure {
@@ -6802,6 +6827,58 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transform: translateX(-50%);
}
.image-canvas-editor__bottom-toolbar--guided {
border-color: #ff4f4f;
box-shadow:
0 18px 38px rgba(15, 23, 42, 0.16),
0 0 0 3px rgba(255, 79, 79, 0.24);
}
.image-canvas-editor__toolbar-guide {
position: absolute;
left: 50%;
bottom: 4.45rem;
z-index: 12;
display: flex;
align-items: flex-end;
gap: 0;
max-width: min(28rem, calc(100% - 2rem));
color: #ffffff;
pointer-events: none;
transform: translateX(-50%);
}
.image-canvas-editor__toolbar-guide-ip {
width: clamp(5.4rem, 12vw, 8.4rem);
height: clamp(5.4rem, 12vw, 8.4rem);
object-fit: contain;
filter: drop-shadow(0 16px 22px rgba(112, 57, 30, 0.18));
}
.image-canvas-editor__toolbar-guide span {
position: relative;
margin-bottom: 0.9rem;
margin-left: -0.55rem;
border-radius: 0.9rem;
background: #f28a2e;
padding: 1rem 1.25rem;
font-size: 0.92rem;
font-weight: 850;
line-height: 1.45;
box-shadow: 0 16px 34px rgba(150, 78, 26, 0.2);
}
.image-canvas-editor__toolbar-guide span::before {
position: absolute;
top: 1.35rem;
left: -0.62rem;
width: 1.1rem;
height: 1.1rem;
background: #f28a2e;
content: "";
transform: rotate(45deg);
}
.image-canvas-editor__bottom-toolbar button[aria-pressed='true'] {
border-color: var(--image-canvas-brand-border-strong);
background: var(--image-canvas-brand-soft);
@@ -9137,6 +9214,25 @@ button.image-canvas-editor__reference-chip:disabled {
transform: none;
}
.image-canvas-editor__toolbar-guide {
right: 0.85rem;
left: 0.85rem;
bottom: 4.75rem;
max-width: none;
transform: none;
}
.image-canvas-editor__toolbar-guide-ip {
width: 5.25rem;
height: 5.25rem;
}
.image-canvas-editor__toolbar-guide span {
margin-bottom: 0.65rem;
padding: 0.82rem 0.95rem;
font-size: 0.82rem;
}
.image-canvas-editor__panel-dock {
left: 0.75rem;
bottom: 3.85rem;