接入创作主页画布直达引导
创作工具卡片接入画布生成器直达意图 新建空白画布增加工具栏引导并在生成面板打开后关闭 暂未开放工具复用移动端欢迎弹窗提示 补充主页、项目入口和画布启动意图测试 同步创作主页入口改版文档
This commit is contained in:
@@ -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 : '创建项目失败',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user