合并 master 并接入 DirectProject 新聊天架构

- 合并 origin/master(304 个提交:DirectProject 聊天容器重构、Project Supervisor 退役、策划附件导入、CI 隔离编译缓存等)。
- 接受 master 对 ProjectSupervisorView / SupervisorChatOnlyView 的退役与预览快捷测试收敛;发布入口改由 DirectProject 聊天头承载。
- DirectProjectChatHeader 新增「发布到游戏广场」入口(无回调不渲染、回合忙态禁用),DirectProjectChatView 透传 onRequestGamePublish。
- App.tsx 继续由工作台壳持有试玩包导出与 GameDistributionPublishPanel,沿用 project.export_package 权限确认队列;check-config 把该命令从 native-only 清单移回 App invoke。
- 后台游戏审核 API / 类型 / 路由测试与 master 新增的 AGC 模板管理按双方保留合并,并修掉拼接造成的接口与用例闭合缺陷。
- 修正 master 自带的 viteProxyConfig 断言:/api/creation-entry 属退役路由,测试改为断言不进入代理。
- 记录合并踩坑:语法结构内部的冲突不能简单按「双方保留」拼接,必须按某一侧骨架重建并跑 tsc 与单文件测试。
- 验证:全量 vitest 393 文件 / 4374 用例通过,root / AGC / admin-web 三端 typecheck,cargo check 与游戏分发 Rust 测试,encoding、doc-index、rustfmt、SpacetimeDB schema guard。
This commit is contained in:
2026-09-22 16:45:29 +08:00
651 changed files with 78860 additions and 60952 deletions
@@ -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>
);
}
@@ -1313,7 +1313,7 @@ export function ImageCanvasWorldView({
{canvasGenerationDialogs.map((dialog) => {
const hasGeneratedLayer = Boolean(
dialog.generatedLayerId &&
layers.some((layer) => layer.id === dialog.generatedLayerId),
layers.some((layer) => layer.id === dialog.generatedLayerId),
);
return dialog.placeholder && !hasGeneratedLayer
? (() => {
@@ -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);
};
+2 -2
View File
@@ -887,7 +887,7 @@ export async function fetchWithApiAuth(
requestHeaders[REQUEST_ID_HEADER] = requestId;
let hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
requestHeaders.authorization?.trim(),
);
if (
@@ -903,7 +903,7 @@ export async function fetchWithApiAuth(
requestHeaders[REQUEST_ID_HEADER] = requestId;
hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
requestHeaders.authorization?.trim(),
);
} catch (error) {
if (requestSignal?.aborted) {
@@ -211,7 +211,8 @@ describe('editorProjectClient', () => {
{ deadlineAt: expect.any(Number) },
);
const requestOptions = requestJsonMock.mock.calls[0]?.[3] as
{ deadlineAt?: number } | undefined;
| { deadlineAt?: number }
| undefined;
expect(requestOptions?.deadlineAt).toBeGreaterThanOrEqual(
startedAt + 60_000,
);
@@ -219,7 +219,9 @@ export type EditorAssetGenerationInputs = {
};
export type EditorProjectResourceSourceType =
'uploaded' | 'generated' | 'mock_generated';
| 'uploaded'
| 'generated'
| 'mock_generated';
export type EditorProjectResourceSnapshot = {
resourceId: string;
@@ -328,7 +330,11 @@ export type EditorImageGenerationInput = {
prompt: string;
size?: string;
kind?:
'spec' | 'character' | 'quick-edit' | 'ui-design' | 'publication-material';
| 'spec'
| 'character'
| 'quick-edit'
| 'ui-design'
| 'publication-material';
model?: string;
screenColor?: string;
segModel?: string;
@@ -538,7 +544,12 @@ export type EditorIconSpritesheetSliceResult = {
export type EditorCharacterAnimationResolution = '480p' | '720p';
export type EditorCharacterAnimationRatio =
'same' | '1:1' | '4:3' | '16:9' | '9:16' | '3:4';
| 'same'
| '1:1'
| '4:3'
| '16:9'
| '9:16'
| '3:4';
export type EditorCharacterAnimationFrameCount = 32 | 40 | 48;
@@ -600,7 +611,12 @@ export type EditorVideoModel =
export type EditorVideoResolution = '480p' | '720p' | '1080p';
export type EditorVideoSoundMode = 'on' | 'off';
export type EditorVideoAspectRatio =
'16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';
| '16:9'
| '9:16'
| '1:1'
| '4:3'
| '3:4'
| '21:9';
export type EditorVideoGenerationInput = {
prompt: string;