合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径 - 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径 - 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现 - 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道 - App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约 - Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核 - 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据 - 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ClientDownloadEntry } from './ClientDownloadEntry';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const installerUrl = (version: string) =>
|
||||
`https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/${version}/setup.exe`;
|
||||
const releaseResponse = (version: string) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
downloads: [
|
||||
{
|
||||
platform: 'windows',
|
||||
architecture: 'x86_64',
|
||||
version,
|
||||
downloadUrl: installerUrl(version),
|
||||
},
|
||||
],
|
||||
unavailablePlatforms: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const macDownload = (architecture: 'aarch64' | 'x86_64') => ({
|
||||
platform: 'macos',
|
||||
architecture,
|
||||
version: '0.2.0',
|
||||
downloadUrl: `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/0.2.0/app_${architecture}.dmg`,
|
||||
});
|
||||
|
||||
describe('官网下载入口', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('无需登录,每次打开重新获取最新版本和安装包链接', async () => {
|
||||
fetchMock.mockImplementationOnce(() =>
|
||||
Promise.resolve(releaseResponse('0.1.73')),
|
||||
);
|
||||
render(<ClientDownloadEntry />);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
expect(
|
||||
(
|
||||
await screen.findByRole('link', { name: '下载 Windows 版' })
|
||||
).getAttribute('href'),
|
||||
).toBe(installerUrl('0.1.73'));
|
||||
expect(screen.getByText('最新版本 v0.1.73')).toBeTruthy();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/client-downloads',
|
||||
expect.objectContaining({ cache: 'no-store', credentials: 'omit' }),
|
||||
);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
fetchMock.mockImplementationOnce(() =>
|
||||
Promise.resolve(releaseResponse('0.1.74')),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
expect(
|
||||
(
|
||||
await screen.findByRole('link', { name: '下载 Windows 版' })
|
||||
).getAttribute('href'),
|
||||
).toBe(installerUrl('0.1.74'));
|
||||
});
|
||||
|
||||
it('服务不可用时不显示下载链接,重试后使用成功响应', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('', { status: 502 }));
|
||||
render(<ClientDownloadEntry />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
fetchMock.mockImplementationOnce(() =>
|
||||
Promise.resolve(releaseResponse('0.1.75')),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }));
|
||||
expect(
|
||||
(
|
||||
await screen.findByRole('link', { name: '下载 Windows 版' })
|
||||
).getAttribute('href'),
|
||||
).toBe(installerUrl('0.1.75'));
|
||||
});
|
||||
|
||||
it('Mac 发布后重新打开即显示各架构和独立版本,未发布时不显示', async () => {
|
||||
fetchMock.mockImplementationOnce(() =>
|
||||
Promise.resolve(releaseResponse('0.1.73')),
|
||||
);
|
||||
render(<ClientDownloadEntry />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
await screen.findByRole('link', { name: '下载 Windows 版' });
|
||||
expect(screen.queryByText(/macOS/u)).toBeNull();
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
fetchMock.mockImplementationOnce(async () => {
|
||||
const windows = await releaseResponse('0.1.74').json();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
...windows,
|
||||
downloads: [
|
||||
...windows.downloads,
|
||||
macDownload('aarch64'),
|
||||
macDownload('x86_64'),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
const arm = await screen.findByRole('region', {
|
||||
name: 'macOS(Apple Silicon)',
|
||||
});
|
||||
const intel = screen.getByRole('region', { name: 'macOS(Intel)' });
|
||||
expect(within(arm).getByText('最新版本 v0.2.0')).toBeTruthy();
|
||||
expect(within(arm).getByRole('link').getAttribute('href')).toBe(
|
||||
macDownload('aarch64').downloadUrl,
|
||||
);
|
||||
expect(within(intel).getByRole('link').getAttribute('href')).toBe(
|
||||
macDownload('x86_64').downloadUrl,
|
||||
);
|
||||
expect(screen.getByText('最新版本 v0.1.74')).toBeTruthy();
|
||||
expect(screen.getAllByRole('link')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('部分平台失败仍可下载其他平台,重试成功后补齐列表', async () => {
|
||||
fetchMock.mockImplementationOnce(async () => {
|
||||
const windows = await releaseResponse('0.1.74').json();
|
||||
return new Response(
|
||||
JSON.stringify({ ...windows, unavailablePlatforms: ['macos'] }),
|
||||
);
|
||||
});
|
||||
render(<ClientDownloadEntry />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
expect(
|
||||
(
|
||||
await screen.findByRole('link', { name: '下载 Windows 版' })
|
||||
).getAttribute('href'),
|
||||
).toBe(installerUrl('0.1.74'));
|
||||
expect(screen.getByRole('alert').textContent).toContain('macOS');
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
downloads: [macDownload('aarch64')],
|
||||
unavailablePlatforms: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }));
|
||||
await screen.findByRole('region', { name: 'macOS(Apple Silicon)' });
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: '下载 Windows 版' })).toBeNull();
|
||||
});
|
||||
|
||||
it('所有平台尚未发布时显示空状态而不伪造链接', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ downloads: [], unavailablePlatforms: [] })),
|
||||
);
|
||||
render(<ClientDownloadEntry />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
expect(await screen.findByText('暂无可下载版本')).toBeTruthy();
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('响应缺少平台列表时显示可重试错误', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('{}'));
|
||||
render(<ClientDownloadEntry />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('关闭取消请求,迟到响应不覆盖重开后版本', async () => {
|
||||
let resolveFirst!: (response: Response) => void;
|
||||
fetchMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
);
|
||||
render(<ClientDownloadEntry />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(signal.aborted).toBe(true);
|
||||
fetchMock.mockImplementationOnce(() =>
|
||||
Promise.resolve(releaseResponse('0.1.76')),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
await screen.findByText('最新版本 v0.1.76');
|
||||
await act(async () => {
|
||||
resolveFirst(releaseResponse('0.1.73'));
|
||||
});
|
||||
expect(screen.getByText('最新版本 v0.1.76')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('请求超时后退出加载并允许重试', async () => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock.mockImplementationOnce(() => new Promise(() => {}));
|
||||
render(<ClientDownloadEntry />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
|
||||
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000);
|
||||
});
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(screen.getByRole('alert')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { getPlatformActionButtonClassName } from '@genarrative/shared/components';
|
||||
import { Download, Monitor } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { ClientDownloadResponse } from '../../../packages/shared/src/contracts/clientDownload';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
|
||||
const DOWNLOAD_ERROR = '暂时无法获取最新版本,请稍后重试';
|
||||
|
||||
type DownloadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; release: ClientDownloadResponse }
|
||||
| { status: 'error' };
|
||||
|
||||
const platformNames = { windows: 'Windows', macos: 'macOS' } as const;
|
||||
|
||||
function downloadLabel(download: ClientDownloadResponse['downloads'][number]) {
|
||||
if (download.platform === 'windows') return 'Windows 64 位';
|
||||
return `macOS(${download.architecture === 'aarch64' ? 'Apple Silicon' : 'Intel'})`;
|
||||
}
|
||||
|
||||
export function ClientDownloadEntry() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState<DownloadState>({ status: 'loading' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const controller = new AbortController();
|
||||
let disposed = false;
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
setState({ status: 'error' });
|
||||
}, 15_000);
|
||||
|
||||
setState({ status: 'loading' });
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/client-downloads', {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
});
|
||||
if (!response.ok) throw new Error(DOWNLOAD_ERROR);
|
||||
const release = (await response.json()) as ClientDownloadResponse;
|
||||
if (
|
||||
!Array.isArray(release.downloads) ||
|
||||
!Array.isArray(release.unavailablePlatforms)
|
||||
) {
|
||||
throw new Error(DOWNLOAD_ERROR);
|
||||
}
|
||||
if (!disposed) setState({ status: 'ready', release });
|
||||
} catch {
|
||||
if (!disposed) setState({ status: 'error' });
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, attempt]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="min-h-11 shrink-0 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
setState({ status: 'loading' });
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
下载客户端
|
||||
</PlatformActionButton>
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="下载陶泥儿客户端"
|
||||
size="sm"
|
||||
>
|
||||
{state.status === 'loading' ? (
|
||||
<PlatformStatusMessage tone="info" role="status">
|
||||
正在获取最新版本…
|
||||
</PlatformStatusMessage>
|
||||
) : state.status === 'error' ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PlatformStatusMessage tone="error" role="alert">
|
||||
{DOWNLOAD_ERROR}
|
||||
</PlatformStatusMessage>
|
||||
<PlatformActionButton
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
重试
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{state.release.downloads.map((download) => (
|
||||
<PlatformSubpanel
|
||||
key={`${download.platform}-${download.architecture}`}
|
||||
as="section"
|
||||
aria-label={downloadLabel(download)}
|
||||
padding="md"
|
||||
radius="md"
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Monitor
|
||||
className="h-7 w-7 shrink-0 text-[var(--platform-accent-strong)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-[var(--platform-text-strong)]">
|
||||
{downloadLabel(download)}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--platform-text-soft)]">
|
||||
最新版本 v{download.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={download.downloadUrl}
|
||||
className={getPlatformActionButtonClassName({
|
||||
size: 'md',
|
||||
fullWidth: true,
|
||||
})}
|
||||
referrerPolicy="no-referrer"
|
||||
>
|
||||
<Download className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{download.platform === 'windows'
|
||||
? '下载 Windows 版'
|
||||
: `下载 ${downloadLabel(download)}`}
|
||||
</a>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
{state.release.downloads.length === 0 ? (
|
||||
<PlatformStatusMessage tone="info" role="status">
|
||||
暂无可下载版本
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{state.release.unavailablePlatforms.length > 0 ? (
|
||||
<PlatformStatusMessage tone="warning" role="alert">
|
||||
{state.release.unavailablePlatforms
|
||||
.map((platform) => platformNames[platform])
|
||||
.join('、')}{' '}
|
||||
版本暂时无法获取
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{state.release.downloads.length === 0 ||
|
||||
state.release.unavailablePlatforms.length > 0 ? (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
重试
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</UnifiedModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -259,13 +259,6 @@ export function ImageCanvasCharacterAnimationPanelView({
|
||||
{isGenerating ? '生成中' : `生成${price}泥点`}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
<span
|
||||
className="image-canvas-editor__character-animation-summary-text"
|
||||
title={panel.promptText.trim() || undefined}
|
||||
aria-label={`生成文本:${panel.promptText.trim() || '动画描述'}`}
|
||||
>
|
||||
{panel.promptText.trim() ? panel.promptText.trim() : '动画描述'}
|
||||
</span>
|
||||
{panel.status === 'completed' && panel.result ? (
|
||||
<PlatformStatusMessage
|
||||
tone="success"
|
||||
|
||||
@@ -3522,9 +3522,13 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
within(panel).getByLabelText('动画描述'),
|
||||
precisePrompt,
|
||||
);
|
||||
// 面板里原本还有一行只读的「生成文本」回显(与上面的输入区重复);它已删除,这里钉住不再渲染。
|
||||
expect(
|
||||
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
|
||||
).toBeTruthy();
|
||||
within(panel).queryByLabelText(`生成文本:${precisePrompt}`),
|
||||
).toBeNull();
|
||||
expect(within(panel).getByLabelText('动画描述').textContent).toContain(
|
||||
precisePrompt,
|
||||
);
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '动画参数 同图尺寸 · 4秒 · 480p',
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
|
||||
import { type ReactNode, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import { PlatformStatusMessage } from '../../../packages/shared/src/components/PlatformStatusMessage';
|
||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import type { EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
@@ -59,13 +66,24 @@ type ImageCanvasProjectAssetPickerDialogProps = {
|
||||
*/
|
||||
errorMessage?: string | null;
|
||||
/**
|
||||
* 「点选替换」:由宿主关闭本弹窗,改在画布上直接点选目标素材。
|
||||
* 非模态浮层:不铺全屏遮罩、不做焦点陷阱,宿主画布保持可点。
|
||||
*
|
||||
* 默认 `undefined` → **不渲染该入口**,弹窗行为与现在逐字不变(网页端美术画布的参考图
|
||||
* 选择不传它,多选与确认流程原样保留)。传了才多出一个按钮,点它不做选择、不回传 id ——
|
||||
* 弹窗只负责把"用户要走点选"这件事告诉宿主,进入与退出点选态都由宿主自己的状态机管。
|
||||
* AGC 的「替换素材」用它:面板开着的时候直接在资源画布上点目标素材,点中的候选落进面板的
|
||||
* 当前选择,写入仍然只由面板的「确认」发起。默认 `false` → 网页端美术画布的弹窗行为逐字不变。
|
||||
*
|
||||
* 打开期间 Esc 仍等于「取消」(document 阶段截断,宿主画布的全局 Esc 不随之触发);
|
||||
* 点外部不关闭——非模态面板与画布是同一屏的两半,点画布是要选目标,不是要关面板。
|
||||
*/
|
||||
onPickFromCanvas?: () => void;
|
||||
nonModal?: boolean;
|
||||
/**
|
||||
* 「初值换了」的信号:宿主在面板**开着**的时候又给了新的 `selectedAssetIds`(AGC 里是在
|
||||
* 画布上点选目标素材),序号一变就按新初值重同步当前选择。
|
||||
*
|
||||
* 不能用 `selectedAssetIds` 的引用当信号:调用方每次渲染都会重建那个数组,把它放进依赖会
|
||||
* 让「父级任何一次重渲染」都清掉用户的选择(组件里原本就是这么写的)。所以同步点交给这个
|
||||
* 显式序号:只有宿主真的换了目标才变化。缺省 `0`(网页端美术画布不传,行为逐字不变)。
|
||||
*/
|
||||
initialSelectionRevision?: number;
|
||||
};
|
||||
|
||||
function assetIcon(category: ProjectAssetPickerCategory) {
|
||||
@@ -121,7 +139,8 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
renderAssetMedia,
|
||||
selectionNoun = '参考图',
|
||||
errorMessage,
|
||||
onPickFromCanvas,
|
||||
nonModal = false,
|
||||
initialSelectionRevision = 0,
|
||||
}: ImageCanvasProjectAssetPickerDialogProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ProjectAssetPickerCategory>('all');
|
||||
@@ -143,6 +162,18 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只在打开的那一帧重置
|
||||
}, [open]);
|
||||
|
||||
/**
|
||||
* 面板开着时宿主换了初值(AGC 在画布上点选目标素材):只同步这一项,别的不动。
|
||||
*
|
||||
* 搜索词与分类保持原样——用户在面板里筛到一半、又去画布上点一张,回来不该被重置成「全部」。
|
||||
* 选择直接落成新初值(单选场景就是那一项)。
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
setSelection([...selectedAssetIds]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只认显式序号,不认数组引用
|
||||
}, [initialSelectionRevision, open]);
|
||||
|
||||
const visibleAssets = useMemo(
|
||||
() =>
|
||||
assets.filter((asset) =>
|
||||
@@ -171,59 +202,63 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title={`选择${selectionNoun}`}
|
||||
size="lg"
|
||||
portalTheme="light"
|
||||
closeLabel={`关闭选择${selectionNoun}`}
|
||||
onClose={onCancel}
|
||||
panelClassName="image-canvas-editor__project-asset-picker"
|
||||
bodyClassName="image-canvas-editor__project-asset-picker-body"
|
||||
footer={
|
||||
<>
|
||||
<span className="mr-auto text-xs text-[var(--platform-text-base)]">
|
||||
已选 {selection.length} 个
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={selection.length === 0}
|
||||
onClick={() => setSelection([])}
|
||||
>
|
||||
清空
|
||||
</PlatformActionButton>
|
||||
{onPickFromCanvas ? (
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onPickFromCanvas}
|
||||
>
|
||||
点选替换
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-label={`确认选择${selectionNoun}`}
|
||||
onClick={() => onConfirm(selection)}
|
||||
>
|
||||
确认
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
/**
|
||||
* 非模态下的 Esc = 取消。
|
||||
*
|
||||
* 挂在 **document** 并 `stopPropagation`:宿主画布的全局 Esc 挂在 window 上(清画布焦点 =
|
||||
* 清选中 + 收浮层),document 在冒泡路径上早于 window,这里截断才能做到「Esc 只收替换面板、
|
||||
* 不连带清画布选中」(与「浮层打开时 Escape 归浮层所有」同一口径)。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!open || !nonModal) {
|
||||
return undefined;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
>
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [nonModal, onCancel, open]);
|
||||
|
||||
const dialogLabel = `选择${selectionNoun}`;
|
||||
const pickerFooter = (
|
||||
<>
|
||||
<span className="mr-auto text-xs text-[var(--platform-text-base)]">
|
||||
已选 {selection.length} 个
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={selection.length === 0}
|
||||
onClick={() => setSelection([])}
|
||||
>
|
||||
清空
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-label={`确认选择${selectionNoun}`}
|
||||
onClick={() => onConfirm(selection)}
|
||||
>
|
||||
确认
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
);
|
||||
const pickerBody = (
|
||||
<>
|
||||
{errorMessage ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
@@ -359,6 +394,57 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
{visibleAssets.length} / {assets.length}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* 非模态浮层:不铺遮罩、不抢焦点,宿主画布保持可点(AGC 的「替换素材」用它——面板开着时
|
||||
* 直接在画布上点目标素材,点中的候选落进这里的当前选择,写入仍然只由「确认」发起)。
|
||||
*/
|
||||
if (nonModal) {
|
||||
// 关掉就是卸载(模态那条路由 `UnifiedModal` 自己按 `open` 返回空):与弹窗同一判据,
|
||||
// 否则宿主把 `open` 置回 false 之后画布右上角还留着半块面板。
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="image-canvas-editor__project-asset-picker image-canvas-editor__project-asset-picker--floating"
|
||||
role="dialog"
|
||||
aria-label={dialogLabel}
|
||||
>
|
||||
<header className="image-canvas-editor__project-asset-picker-header">
|
||||
<strong>{dialogLabel}</strong>
|
||||
<PlatformModalCloseButton
|
||||
variant="platformIcon"
|
||||
placement="inline"
|
||||
label={`关闭选择${selectionNoun}`}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</header>
|
||||
<div className="image-canvas-editor__project-asset-picker-scroll">
|
||||
{pickerBody}
|
||||
</div>
|
||||
<footer className="image-canvas-editor__project-asset-picker-actions">
|
||||
{pickerFooter}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title={dialogLabel}
|
||||
size="lg"
|
||||
portalTheme="light"
|
||||
closeLabel={`关闭选择${selectionNoun}`}
|
||||
onClose={onCancel}
|
||||
panelClassName="image-canvas-editor__project-asset-picker"
|
||||
bodyClassName="image-canvas-editor__project-asset-picker-body"
|
||||
footer={pickerFooter}
|
||||
>
|
||||
{pickerBody}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type FloatingOptionBoundaryRef = {
|
||||
readonly current: HTMLElement | null;
|
||||
@@ -107,19 +107,48 @@ export function useImageCanvasFloatingOptionDismiss({
|
||||
restoreFocusRef,
|
||||
isInsideExtraOverlay,
|
||||
}: UseImageCanvasFloatingOptionDismissOptions) {
|
||||
/**
|
||||
* 本次点击「算不算点在浮层里」的冻结判定。
|
||||
*
|
||||
* 判定必须在事件派发的最早期做,不能在冒泡到 `document` 时才拿 `event.target` 现算:
|
||||
* 浮层里的按钮可能在**这一次点击**里把自己卸载掉(AGC 画布验收 AGC-005:快速编辑的
|
||||
* 「恢复原文」被点后原文快照清空、按钮不再渲染,验收视频里表现为整个编辑面板连同选中
|
||||
* 一起被收掉)。目标节点一旦脱离 DOM,`element.contains(target)` 一律为 false,
|
||||
* 「点浮层内部」就被误判成「点外部」,面板被自己的按钮关掉。
|
||||
*
|
||||
* 捕获阶段挂在 `document` 上是最早的一站(React 的委托监听在 root 容器上,位置更靠内),
|
||||
* 此刻 DOM 还是用户看到的那一份;判定口径本身不变,仍是
|
||||
* {@link isEventInsideFloatingOverlay}(边界 DOM + portal 到 body 的浮层登记)。
|
||||
* 真正触发关闭仍然留在冒泡阶段,宿主其它处理器看到的事件顺序与既有行为逐字一致。
|
||||
*/
|
||||
const clickVerdictRef = useRef<{
|
||||
event: MouseEvent;
|
||||
inside: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolveInside = (event: MouseEvent) =>
|
||||
isEventInsideFloatingOverlay(event.target, {
|
||||
boundaryRefs,
|
||||
isInsideExtraOverlay,
|
||||
});
|
||||
|
||||
const handleClickCapture = (event: MouseEvent) => {
|
||||
clickVerdictRef.current = { event, inside: resolveInside(event) };
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
|
||||
if (
|
||||
isEventInsideFloatingOverlay(event.target, {
|
||||
boundaryRefs,
|
||||
isInsideExtraOverlay,
|
||||
})
|
||||
) {
|
||||
const frozen = clickVerdictRef.current;
|
||||
clickVerdictRef.current = null;
|
||||
// 捕获阶段没跑到(例如事件不是这条派发路径派出来的)时退回现算,行为与改动前一致。
|
||||
const isInside =
|
||||
frozen && frozen.event === event ? frozen.inside : resolveInside(event);
|
||||
if (isInside) {
|
||||
return;
|
||||
}
|
||||
onDismiss();
|
||||
@@ -144,9 +173,11 @@ export function useImageCanvasFloatingOptionDismiss({
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickCapture, true);
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickCapture, true);
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
|
||||
@@ -230,6 +230,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('泥点 207')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<PlatformEntryFlowShellImpl
|
||||
@@ -484,6 +485,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['我的']);
|
||||
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
|
||||
expect(
|
||||
within(navigation)
|
||||
.getByRole('button', { name: '我的' })
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry';
|
||||
import {
|
||||
PlatformActiveMobileWelcomeDialog,
|
||||
shouldOpenActiveMobileWelcomeDialog,
|
||||
@@ -494,7 +495,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
</aside>
|
||||
|
||||
<div className="platform-desktop-main flex min-w-0 flex-1 flex-col">
|
||||
<header className="platform-desktop-topbar flex min-h-16 shrink-0 items-center justify-between gap-1 px-4 sm:gap-3 sm:px-6">
|
||||
<header className="platform-desktop-topbar flex min-h-16 shrink-0 flex-wrap items-center justify-between gap-1 gap-y-2 px-4 py-2 sm:flex-nowrap sm:gap-3 sm:px-6 sm:py-0">
|
||||
<div className="min-w-0 shrink-0 lg:hidden">
|
||||
<ActivePlatformBrand />
|
||||
</div>
|
||||
@@ -534,7 +535,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="platform-desktop-topbar__actions flex shrink-0 items-center gap-1 sm:gap-2 lg:gap-3">
|
||||
<div className="platform-desktop-topbar__actions flex max-w-full shrink-0 flex-wrap items-center gap-1 sm:flex-nowrap sm:gap-2 lg:gap-3">
|
||||
<ClientDownloadEntry />
|
||||
{isAuthenticated ? (
|
||||
<PlatformMudPointWalletEntry
|
||||
variant={isDesktopLayout ? 'desktop' : 'mobile'}
|
||||
|
||||
@@ -4,6 +4,7 @@ const FRONTEND_RUNTIME_CONFIG_API = '/api/runtime/frontend-config';
|
||||
|
||||
export type FrontendRuntimeConfig = {
|
||||
imageEditorAgentSidebarEnabled: boolean;
|
||||
agcTemplateLibraryEnabled: boolean;
|
||||
};
|
||||
|
||||
export async function loadFrontendRuntimeConfig() {
|
||||
|
||||
Reference in New Issue
Block a user