新增「导出到」全屏面板并复用 DirectProject 对话盒
新增 project-development/export 全屏导出面板:目标大卡、等宽下划线 tab、小红书工作台分栏与第二 subscriber 会话。 面板状态提升到 usePanel 根,tab 页可卸载;输入草稿提升到面板层,切 tab 不丢字。 抽出 DirectProjectChatBox,主聊天与导出会话复用同一套对话盒,不再另写版式。 DirectProjectChatHeader 增加「导出」入口,App.tsx 条件挂载全屏面板。 DirectProjectComposer 增加 referencesEnabled 与 draft/onDraftChange,导出输入区关闭素材引用。 新增 tests/artifactsPanel.test.tsx 覆盖卡片、tab、关闭与对话盒复用。
This commit is contained in:
@@ -108,6 +108,7 @@ import {
|
||||
DirectProjectChatView,
|
||||
type DirectProjectInitialTurn,
|
||||
} from './view/project-development/chat/DirectProjectChatView';
|
||||
import ArtifactsPanel from './view/project-development/export';
|
||||
import { PlanningChatView } from './view/project-development/planning/PlanningChatView';
|
||||
import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation';
|
||||
import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel';
|
||||
@@ -329,6 +330,8 @@ export function App({
|
||||
const [publishPackageResult, setPublishPackageResult] =
|
||||
useState<LocalProjectExportPackageResult | null>(null);
|
||||
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
|
||||
// 导出产物:全屏面板由工作台壳按需挂载,关闭即卸载,面板状态不留在壳上。
|
||||
const [exportPanelOpen, setExportPanelOpen] = useState(false);
|
||||
const [publishProgress, setPublishProgress] =
|
||||
useState<GamePublishProgressState | null>(null);
|
||||
const [publishBlockedMessage, setPublishBlockedMessage] = useState<
|
||||
@@ -1850,12 +1853,25 @@ export function App({
|
||||
onRequestGamePublish={
|
||||
gamePublishAllowed ? requestGamePublish : undefined
|
||||
}
|
||||
onRequestExport={
|
||||
localProject?.projectPath || projectPath
|
||||
? () => setExportPanelOpen(true)
|
||||
: undefined
|
||||
}
|
||||
onCancelConfirmation={cancelUiCommandConfirmation}
|
||||
onConfirmConfirmation={confirmUiCommand}
|
||||
pendingConfirmation={pendingUiConfirmation}
|
||||
projectPath={localProject?.projectPath ?? projectPath ?? null}
|
||||
ref={directProjectChatRef}
|
||||
/>
|
||||
{exportPanelOpen ? (
|
||||
<ArtifactsPanel
|
||||
projectPath={localProject?.projectPath ?? projectPath ?? null}
|
||||
ensureConversationReadAllowed={ensureDirectHistoryReadAllowed}
|
||||
ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed}
|
||||
onClose={() => setExportPanelOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
<GameDistributionPublishPanel
|
||||
open={publishPanelOpen}
|
||||
projectPath={localProject?.projectPath ?? projectPath ?? ''}
|
||||
|
||||
+27
-108
@@ -13,15 +13,8 @@ import type { PendingUiConfirmation } from '../../../app/types';
|
||||
import { projectNameFromPath } from '../../../features/agent-runtime';
|
||||
import { registerActiveChatComposer } from '../../../features/project-workspace/activeChatComposer';
|
||||
import { userItemFromContent } from '../../../features/project-workspace/resourceReferences';
|
||||
import { type ApprovalMode, approvalModeLabel } from '../approvalMode';
|
||||
import { ApprovalModeDialog } from '../ApprovalModeDialog';
|
||||
import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader';
|
||||
import {
|
||||
DirectProjectComposer,
|
||||
type DirectProjectComposerHandle,
|
||||
} from './components/DirectProjectComposer/DirectProjectComposer';
|
||||
import { DirectProjectConversation } from './components/DirectProjectConversation/DirectProjectConversation';
|
||||
import { DirectProjectSettingsDialog } from './components/DirectProjectSettingsDialog/DirectProjectSettingsDialog';
|
||||
import { DirectProjectChatBox } from './components/DirectProjectChatBox/DirectProjectChatBox';
|
||||
import type { DirectProjectComposerHandle } from './components/DirectProjectComposer/DirectProjectComposer';
|
||||
import {
|
||||
type DirectProjectConversationReadGate,
|
||||
type DirectProjectConversationWriteGate,
|
||||
@@ -84,6 +77,8 @@ export type DirectProjectChatViewProps = {
|
||||
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
|
||||
/** 发布入口:由工作台壳持有导出与发布面板,聊天只提供触发按钮。 */
|
||||
onRequestGamePublish?: () => void;
|
||||
/** 导出产物入口:由工作台壳持有导出面板,聊天只提供触发按钮。 */
|
||||
onRequestExport?: () => void;
|
||||
/**
|
||||
* 工作台壳入队的项目权限确认:聊天只展示并转发确认/取消,
|
||||
* 策略读取、拒绝判定和确认后的动作仍由工作台壳持有。
|
||||
@@ -97,6 +92,7 @@ export type DirectProjectChatViewProps = {
|
||||
|
||||
export function DirectProjectChatView({
|
||||
onRequestGamePublish,
|
||||
onRequestExport,
|
||||
onConfirmConfirmation,
|
||||
onCancelConfirmation,
|
||||
pendingConfirmation = null,
|
||||
@@ -110,10 +106,6 @@ export function DirectProjectChatView({
|
||||
useDirectProjectManifest(projectPath);
|
||||
const composerHandleRef = useRef<DirectProjectComposerHandle | null>(null);
|
||||
const [runtimeNotice, setRuntimeNotice] = useState('');
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [approvalOpen, setApprovalOpen] = useState(false);
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
|
||||
const [approvalNotice, setApprovalNotice] = useState('');
|
||||
/** 运行态提示出口:失败说明只展示安全文案,诊断线索留在宿主侧。 */
|
||||
const handleRuntimeError = useCallback((message: string) => {
|
||||
setRuntimeNotice(message);
|
||||
@@ -129,27 +121,14 @@ export function DirectProjectChatView({
|
||||
});
|
||||
const {
|
||||
appendLocalMessage,
|
||||
attachmentNotice,
|
||||
attachmentsImporting,
|
||||
cancelPendingTurn,
|
||||
cancelTurn,
|
||||
commandInFlight,
|
||||
composerNotice,
|
||||
directEntries,
|
||||
directTurnRunning,
|
||||
directTurnStartedAt,
|
||||
historyError,
|
||||
historyHasMore,
|
||||
historyLoading,
|
||||
loadEarlierHistory,
|
||||
retryEarlierHistory,
|
||||
localMessages,
|
||||
pendingTurns,
|
||||
startInitialTurn,
|
||||
statusNotice,
|
||||
submit,
|
||||
turnCancelling,
|
||||
uploadFiles,
|
||||
} = chat;
|
||||
// 这份投影要参与下游 memo 的判据,必须自己先缓存:不缓存的话每次渲染都是一份新数组,
|
||||
// `useDirectProjectTurnStatus` 里以 `turns` 为判据的 `useMemo` 永远命中不了,那层 memo
|
||||
@@ -241,87 +220,27 @@ export function DirectProjectChatView({
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="project-chat-surface is-direct-codex"
|
||||
aria-label="陶泥儿项目对话"
|
||||
>
|
||||
<div className="project-chat-conversation">
|
||||
<DirectProjectChatHeader
|
||||
busy={turnStatus.displayBusy}
|
||||
statusText={statusText}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onRequestGamePublish={onRequestGamePublish}
|
||||
/>
|
||||
<DirectProjectConversation
|
||||
conversationKey={projectPath ?? ''}
|
||||
turns={directTurns}
|
||||
historyHasMore={historyHasMore}
|
||||
historyLoading={historyLoading}
|
||||
historyError={historyError}
|
||||
turnInFlight={turnStatus.displayBusy}
|
||||
activeTurnStartedAt={activeTurnStartedAt}
|
||||
onLoadEarlierHistory={() => void loadEarlierHistory()}
|
||||
onRetryEarlierHistory={() => void retryEarlierHistory()}
|
||||
/>
|
||||
{pendingConfirmation &&
|
||||
onConfirmConfirmation &&
|
||||
onCancelConfirmation ? (
|
||||
<div className="pending-command">
|
||||
<span>
|
||||
{pendingConfirmation.commandId}
|
||||
<small>{pendingConfirmation.detail}</small>
|
||||
</span>
|
||||
<button type="button" onClick={onCancelConfirmation}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={onConfirmConfirmation}>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<DirectProjectComposer
|
||||
ref={composerHandleRef}
|
||||
assets={assets}
|
||||
{...(versions ? { versions } : {})}
|
||||
projectPath={projectPath}
|
||||
attachmentNotice={attachmentNotice}
|
||||
attachmentsImporting={attachmentsImporting}
|
||||
pendingTurns={pendingTurns}
|
||||
composerNotice={composerNotice}
|
||||
busy={turnStatus.displayBusy}
|
||||
canStopTurn={turnStatus.canStopTurn}
|
||||
turnCancelling={turnCancelling}
|
||||
onCancelPendingTurn={(clientTurnId) =>
|
||||
void cancelPendingTurn(clientTurnId)
|
||||
}
|
||||
onCancelTurn={() => void cancelTurn()}
|
||||
onReferencePickerOpen={() => {
|
||||
// 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件:
|
||||
// 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。
|
||||
if (projectPath) void refresh(projectPath);
|
||||
}}
|
||||
onSubmit={submit}
|
||||
onUploadFiles={uploadFiles}
|
||||
/>
|
||||
</div>
|
||||
{settingsOpen ? (
|
||||
<DirectProjectSettingsDialog
|
||||
projectPath={projectPath ?? ''}
|
||||
currentApprovalLabel={approvalModeLabel(approvalMode)}
|
||||
onOpenApproval={() => setApprovalOpen(true)}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{settingsOpen && approvalOpen ? (
|
||||
<ApprovalModeDialog
|
||||
approvalMode={approvalMode}
|
||||
notice={approvalNotice}
|
||||
onSelect={setApprovalMode}
|
||||
onNotice={setApprovalNotice}
|
||||
onClose={() => setApprovalOpen(false)}
|
||||
closeOnEscape={false}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
<DirectProjectChatBox
|
||||
ariaLabel="陶泥儿项目对话"
|
||||
controller={chat}
|
||||
assets={assets}
|
||||
{...(versions ? { versions } : {})}
|
||||
projectPath={projectPath}
|
||||
turns={directTurns}
|
||||
turnStatus={turnStatus}
|
||||
activeTurnStartedAt={activeTurnStartedAt}
|
||||
statusText={statusText}
|
||||
{...(onRequestGamePublish ? { onRequestGamePublish } : {})}
|
||||
{...(onRequestExport ? { onRequestExport } : {})}
|
||||
{...(pendingConfirmation ? { pendingConfirmation } : {})}
|
||||
{...(onConfirmConfirmation ? { onConfirmConfirmation } : {})}
|
||||
{...(onCancelConfirmation ? { onCancelConfirmation } : {})}
|
||||
composerRef={composerHandleRef}
|
||||
onReferencePickerOpen={() => {
|
||||
// 素材标签、改名、删除这些写入发生在资源画布,聊天侧收不到失效事件:
|
||||
// 打开 `@` 选择器时按需重读聊天自己那份清单,别把旧标签当现状。
|
||||
if (projectPath) void refresh(projectPath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import { type Ref, useState } from 'react';
|
||||
|
||||
import type {
|
||||
GameCreationAppAssetManifestEntry,
|
||||
GameIterationVersion,
|
||||
} from '../../../../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type { PendingUiConfirmation } from '../../../../../app/types';
|
||||
import type { ChatComposerDraft } from '../../../../../features/project-workspace/resourceReferences';
|
||||
import { type ApprovalMode, approvalModeLabel } from '../../../approvalMode';
|
||||
import { ApprovalModeDialog } from '../../../ApprovalModeDialog';
|
||||
import type { useDirectProjectChatController } from '../../controller/useDirectProjectChatController';
|
||||
import type { useDirectProjectTurnStatus } from '../../controller/useDirectProjectTurnStatus';
|
||||
import type { DirectChatTurn } from '../../conversation/directTurnPresentation';
|
||||
import type { UserContentPart } from '../../generated/UserContentPart';
|
||||
import { DirectProjectChatHeader } from '../DirectProjectChatHeader/DirectProjectChatHeader';
|
||||
import {
|
||||
DirectProjectComposer,
|
||||
type DirectProjectComposerHandle,
|
||||
} from '../DirectProjectComposer/DirectProjectComposer';
|
||||
import { DirectProjectConversation } from '../DirectProjectConversation/DirectProjectConversation';
|
||||
import { DirectProjectSettingsDialog } from '../DirectProjectSettingsDialog/DirectProjectSettingsDialog';
|
||||
|
||||
type DirectProjectChatController = ReturnType<
|
||||
typeof useDirectProjectChatController
|
||||
>;
|
||||
type DirectProjectTurnStatus = ReturnType<typeof useDirectProjectTurnStatus>;
|
||||
|
||||
/**
|
||||
* DirectProject 对话盒的共用表现层:一个 `.project-chat-surface.is-direct-codex`,
|
||||
* 里面就是顶栏 + 消息流 + 输入区这**同一套现成组件**。
|
||||
*
|
||||
* 主聊天 (`DirectProjectChatView`) 与导出面板(同一 thread 的第二个 subscriber)
|
||||
* 都渲染这一份,所以两处的样式与交互天然一致;差别只在传进来的 controller、
|
||||
* 几个入口回调,以及输入区是否启用素材引用。
|
||||
*
|
||||
* 会话真相(订阅、回合、待发队列、附件)不在这一层:它属于调用方持有的 controller。
|
||||
* 这一层只负责把 controller 的投影渲染成原样的对话盒,并持有设置/审批弹层这类
|
||||
* 纯展示状态。
|
||||
*
|
||||
* 注意几何:这套样式挂在 `.game-workbench-chat` 祖先下才成立
|
||||
* (见 `styles.css` 的 `.game-workbench-chat .project-chat-surface` 链),
|
||||
* 调用方要把它放进 `<aside className="game-workbench-chat">`。
|
||||
*/
|
||||
export type DirectProjectChatBoxProps = {
|
||||
/** 对话盒语义标签:主聊天与导出会话各给一条。 */
|
||||
ariaLabel: string;
|
||||
controller: DirectProjectChatController;
|
||||
assets: GameCreationAppAssetManifestEntry[];
|
||||
versions?: GameIterationVersion[];
|
||||
projectPath: string | null;
|
||||
turns: DirectChatTurn[];
|
||||
turnStatus: DirectProjectTurnStatus;
|
||||
activeTurnStartedAt: number;
|
||||
statusText: string;
|
||||
onRequestGamePublish?: () => void;
|
||||
onRequestExport?: () => void;
|
||||
pendingConfirmation?: PendingUiConfirmation | null;
|
||||
onConfirmConfirmation?: () => void;
|
||||
onCancelConfirmation?: () => void;
|
||||
/** 输入区句柄出口:主聊天拿它注册「活跃聊天输入区」。 */
|
||||
composerRef?: Ref<DirectProjectComposerHandle>;
|
||||
onReferencePickerOpen?: () => void;
|
||||
referencesEnabled?: boolean;
|
||||
draft?: UserContentPart[];
|
||||
onDraftChange?: (draft: ChatComposerDraft) => void;
|
||||
};
|
||||
|
||||
export function DirectProjectChatBox({
|
||||
ariaLabel,
|
||||
controller,
|
||||
assets,
|
||||
versions,
|
||||
projectPath,
|
||||
turns,
|
||||
turnStatus,
|
||||
activeTurnStartedAt,
|
||||
statusText,
|
||||
onRequestGamePublish,
|
||||
onRequestExport,
|
||||
pendingConfirmation = null,
|
||||
onConfirmConfirmation,
|
||||
onCancelConfirmation,
|
||||
composerRef,
|
||||
onReferencePickerOpen,
|
||||
referencesEnabled,
|
||||
draft,
|
||||
onDraftChange,
|
||||
}: DirectProjectChatBoxProps) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [approvalOpen, setApprovalOpen] = useState(false);
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>('strict');
|
||||
const [approvalNotice, setApprovalNotice] = useState('');
|
||||
return (
|
||||
<section
|
||||
className="project-chat-surface is-direct-codex"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="project-chat-conversation">
|
||||
<DirectProjectChatHeader
|
||||
busy={turnStatus.displayBusy}
|
||||
statusText={statusText}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
{...(onRequestGamePublish ? { onRequestGamePublish } : {})}
|
||||
{...(onRequestExport ? { onRequestExport } : {})}
|
||||
/>
|
||||
<DirectProjectConversation
|
||||
conversationKey={projectPath ?? ''}
|
||||
turns={turns}
|
||||
historyHasMore={controller.historyHasMore}
|
||||
historyLoading={controller.historyLoading}
|
||||
historyError={controller.historyError}
|
||||
turnInFlight={turnStatus.displayBusy}
|
||||
activeTurnStartedAt={activeTurnStartedAt}
|
||||
onLoadEarlierHistory={() => void controller.loadEarlierHistory()}
|
||||
onRetryEarlierHistory={() => void controller.retryEarlierHistory()}
|
||||
/>
|
||||
{pendingConfirmation &&
|
||||
onConfirmConfirmation &&
|
||||
onCancelConfirmation ? (
|
||||
<div className="pending-command">
|
||||
<span>
|
||||
{pendingConfirmation.commandId}
|
||||
<small>{pendingConfirmation.detail}</small>
|
||||
</span>
|
||||
<button type="button" onClick={onCancelConfirmation}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={onConfirmConfirmation}>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<DirectProjectComposer
|
||||
ref={composerRef}
|
||||
assets={assets}
|
||||
{...(versions ? { versions } : {})}
|
||||
projectPath={projectPath}
|
||||
attachmentNotice={controller.attachmentNotice}
|
||||
attachmentsImporting={controller.attachmentsImporting}
|
||||
pendingTurns={controller.pendingTurns}
|
||||
composerNotice={controller.composerNotice}
|
||||
busy={turnStatus.displayBusy}
|
||||
canStopTurn={turnStatus.canStopTurn}
|
||||
turnCancelling={controller.turnCancelling}
|
||||
onCancelPendingTurn={(clientTurnId) =>
|
||||
void controller.cancelPendingTurn(clientTurnId)
|
||||
}
|
||||
onCancelTurn={() => void controller.cancelTurn()}
|
||||
{...(onReferencePickerOpen ? { onReferencePickerOpen } : {})}
|
||||
onSubmit={controller.submit}
|
||||
onUploadFiles={controller.uploadFiles}
|
||||
{...(referencesEnabled === undefined ? {} : { referencesEnabled })}
|
||||
{...(draft ? { draft } : {})}
|
||||
{...(onDraftChange ? { onDraftChange } : {})}
|
||||
/>
|
||||
</div>
|
||||
{settingsOpen ? (
|
||||
<DirectProjectSettingsDialog
|
||||
projectPath={projectPath ?? ''}
|
||||
currentApprovalLabel={approvalModeLabel(approvalMode)}
|
||||
onOpenApproval={() => setApprovalOpen(true)}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{settingsOpen && approvalOpen ? (
|
||||
<ApprovalModeDialog
|
||||
approvalMode={approvalMode}
|
||||
notice={approvalNotice}
|
||||
onSelect={setApprovalMode}
|
||||
onNotice={setApprovalNotice}
|
||||
onClose={() => setApprovalOpen(false)}
|
||||
closeOnEscape={false}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import { Rocket, Settings } from 'lucide-react';
|
||||
import { Package, Rocket, Settings } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* DirectProject 聊天头:运行状态与设置入口。
|
||||
@@ -11,12 +11,15 @@ export function DirectProjectChatHeader({
|
||||
statusText,
|
||||
onOpenSettings,
|
||||
onRequestGamePublish,
|
||||
onRequestExport,
|
||||
}: {
|
||||
busy: boolean;
|
||||
statusText: string;
|
||||
onOpenSettings: () => void;
|
||||
/** 没有项目或不在 Tauri 宿主时不传;传了才渲染发布入口。 */
|
||||
onRequestGamePublish?: () => void;
|
||||
/** 没有项目时不传;传了才渲染导出产物入口。 */
|
||||
onRequestExport?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<header className="project-chat-topbar">
|
||||
@@ -28,6 +31,18 @@ export function DirectProjectChatHeader({
|
||||
{busy ? '陶泥儿正在处理' : statusText}
|
||||
</span>
|
||||
<div className="project-chat-topbar-actions">
|
||||
{onRequestExport ? (
|
||||
<button
|
||||
type="button"
|
||||
className="project-chat-publish-trigger"
|
||||
aria-label="导出产物"
|
||||
title="导出产物"
|
||||
onClick={onRequestExport}
|
||||
>
|
||||
<Package size={16} aria-hidden="true" />
|
||||
<span>导出</span>
|
||||
</button>
|
||||
) : null}
|
||||
{onRequestGamePublish ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+22
-12
@@ -44,6 +44,13 @@ import { pendingTurnChipLabel } from './pendingTurnChipLabel';
|
||||
|
||||
type ComposerAttachmentMenuProps = {
|
||||
disabled: boolean;
|
||||
/**
|
||||
* 是否渲染「引用项目素材」入口,默认 `true`。
|
||||
*
|
||||
* 导出面板的输入区关掉引用后只剩「上传本地文件」,菜单里不再出现素材选择器,
|
||||
* 避免一个点开必然空转的入口。
|
||||
*/
|
||||
referencePickerEnabled?: boolean;
|
||||
onPickFiles: (files: readonly File[]) => void;
|
||||
onOpenReferencePicker: () => void;
|
||||
};
|
||||
@@ -51,6 +58,7 @@ type ComposerAttachmentMenuProps = {
|
||||
/** 左侧 `+`:独立弹层给两条路径——上传本地文件、引用项目素材。 */
|
||||
export function ComposerAttachmentMenu({
|
||||
disabled,
|
||||
referencePickerEnabled = true,
|
||||
onPickFiles,
|
||||
onOpenReferencePicker,
|
||||
}: ComposerAttachmentMenuProps) {
|
||||
@@ -133,18 +141,20 @@ export function ComposerAttachmentMenu({
|
||||
<FileUp size={14} aria-hidden="true" />
|
||||
<span>上传本地文件</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onOpenReferencePicker();
|
||||
}}
|
||||
>
|
||||
<Images size={14} aria-hidden="true" />
|
||||
<span>引用项目素材</span>
|
||||
</button>
|
||||
{referencePickerEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onOpenReferencePicker();
|
||||
}}
|
||||
>
|
||||
<Images size={14} aria-hidden="true" />
|
||||
<span>引用项目素材</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
+65
-31
@@ -31,6 +31,7 @@ import {
|
||||
} from '../../../../../features/project-workspace/ResourceReferencePicker';
|
||||
import {
|
||||
assetsSignature,
|
||||
type ChatComposerDraft,
|
||||
type ChatReference,
|
||||
} from '../../../../../features/project-workspace/resourceReferences';
|
||||
import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments';
|
||||
@@ -79,6 +80,9 @@ export function DirectProjectComposer({
|
||||
onReferencePickerOpen,
|
||||
onSubmit,
|
||||
onUploadFiles,
|
||||
referencesEnabled = true,
|
||||
draft,
|
||||
onDraftChange,
|
||||
ref,
|
||||
}: {
|
||||
assets: GameCreationAppAssetManifestEntry[];
|
||||
@@ -125,6 +129,21 @@ export function DirectProjectComposer({
|
||||
files: readonly File[],
|
||||
draftAttachmentCount: number,
|
||||
) => Promise<DirectCodexTurnAttachment[]>;
|
||||
/**
|
||||
* 是否启用素材 / Skill 引用(`@` / `$`)。
|
||||
*
|
||||
* 默认 `true`(主聊天)。导出面板那条链路传 `false`:不注入资源 / Skill / 运行区 provider,
|
||||
* 不渲染 `@` 触发钮与素材选择器,输入区只留文本与附件。
|
||||
*/
|
||||
referencesEnabled?: boolean;
|
||||
/**
|
||||
* 提升到宿主的输入草稿:Composer 被卸载再挂回来时用它恢复正文。
|
||||
*
|
||||
* 导出面板切 tab 会卸载 tab 页,草稿留在面板层(`useXiaohongshuTab`)才不丢字。
|
||||
*/
|
||||
draft?: UserContentPart[];
|
||||
/** 草稿变化回执:宿主持有唯一一份草稿,Composer 只负责投影。 */
|
||||
onDraftChange?: (draft: ChatComposerDraft) => void;
|
||||
/**
|
||||
* 输入区句柄出口:`DirectProjectChatView` 拿它注册「活跃聊天输入区」,
|
||||
* 画布的「引用」/拖拽批量引用事件才能落进这份草稿。
|
||||
@@ -147,22 +166,28 @@ export function DirectProjectComposer({
|
||||
const skillProvider = useSkillReferenceProvider();
|
||||
const assetsContentSignature = assetsSignature(assets);
|
||||
const referenceProviders = useMemo(
|
||||
() => [
|
||||
createResourceReferenceProvider({ assets }),
|
||||
skillProvider,
|
||||
attachmentReferenceProvider,
|
||||
runtimeRegionReferenceProvider,
|
||||
],
|
||||
() =>
|
||||
referencesEnabled
|
||||
? [
|
||||
createResourceReferenceProvider({ assets }),
|
||||
skillProvider,
|
||||
attachmentReferenceProvider,
|
||||
runtimeRegionReferenceProvider,
|
||||
]
|
||||
: // 关掉引用后只留附件 provider:上传的附件芯片仍然要能解析出来,
|
||||
// 但 `@` / `$` 两类候选菜单不会再被注册。
|
||||
[attachmentReferenceProvider],
|
||||
// `assets` 由 manifest 订阅每次渲染重新 `.filter()` 派生,依赖它等于从不命中缓存:
|
||||
// provider 身份一变,输入区就重新注册 Lexical 更新监听(流式回复时每帧都换)。
|
||||
// 按内容签名取依赖才真的能跳过重建。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[assetsContentSignature, skillProvider],
|
||||
[assetsContentSignature, referencesEnabled, skillProvider],
|
||||
);
|
||||
const openPicker = useCallback(() => {
|
||||
if (!referencesEnabled) return;
|
||||
onReferencePickerOpen?.();
|
||||
pickerRef.current?.open();
|
||||
}, [onReferencePickerOpen]);
|
||||
}, [onReferencePickerOpen, referencesEnabled]);
|
||||
/**
|
||||
* 导入成功即插芯片:附件只有「正文芯片」这一份事实源,没有第二份待发列表可以回退。
|
||||
* 失败 / 异常一律不插。导入期间允许多次点击:各自的结果插各自的芯片。
|
||||
@@ -260,24 +285,31 @@ export function DirectProjectComposer({
|
||||
projectPath={projectPath ?? ''}
|
||||
disabled={modelValidating}
|
||||
rows={3}
|
||||
placeholder="描述你的想法,或 @ 引用素材"
|
||||
placeholder={
|
||||
referencesEnabled ? '描述你的想法,或 @ 引用素材' : '描述你的想法'
|
||||
}
|
||||
submitSuppressed={pickerOpen}
|
||||
containerRef={composerRootRef}
|
||||
{...(draft ? { initialContent: draft } : {})}
|
||||
{...(onDraftChange ? { onChange: onDraftChange } : {})}
|
||||
/>
|
||||
<div className="project-chat-composer-controls">
|
||||
<div className="project-chat-composer-controls-left">
|
||||
<button
|
||||
type="button"
|
||||
className="project-chat-reference-trigger"
|
||||
aria-label="插入素材引用"
|
||||
title="插入素材引用"
|
||||
disabled={busy}
|
||||
onClick={openPicker}
|
||||
>
|
||||
<AtSign size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{referencesEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="project-chat-reference-trigger"
|
||||
aria-label="插入素材引用"
|
||||
title="插入素材引用"
|
||||
disabled={busy}
|
||||
onClick={openPicker}
|
||||
>
|
||||
<AtSign size={15} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<ComposerAttachmentMenu
|
||||
disabled={busy || modelValidating}
|
||||
referencePickerEnabled={referencesEnabled}
|
||||
onPickFiles={(files) => void uploadFiles(files)}
|
||||
onOpenReferencePicker={openPicker}
|
||||
/>
|
||||
@@ -312,19 +344,21 @@ export function DirectProjectComposer({
|
||||
) : null}
|
||||
{/*
|
||||
素材选择器由宿主渲染、自己拿数据:确认后只把选中的引用交给输入区句柄,
|
||||
输入区与它之间只有 `insertReferences` 这一个接缝。
|
||||
输入区与它之间只有 `insertReferences` 这一个接缝。关掉引用时整块不挂载。
|
||||
*/}
|
||||
<ResourceReferencePicker
|
||||
ref={pickerRef}
|
||||
anchorRef={composerRootRef}
|
||||
assets={assets}
|
||||
{...(versions ? { versions } : {})}
|
||||
projectPath={projectPath ?? ''}
|
||||
onInsert={(references) =>
|
||||
composerRef.current?.insertReferences(references)
|
||||
}
|
||||
onOpenChange={setPickerOpen}
|
||||
/>
|
||||
{referencesEnabled ? (
|
||||
<ResourceReferencePicker
|
||||
ref={pickerRef}
|
||||
anchorRef={composerRootRef}
|
||||
assets={assets}
|
||||
{...(versions ? { versions } : {})}
|
||||
projectPath={projectPath ?? ''}
|
||||
onInsert={(references) =>
|
||||
composerRef.current?.insertReferences(references)
|
||||
}
|
||||
onOpenChange={setPickerOpen}
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { type Target, TARGET_META } from '../model';
|
||||
|
||||
/**
|
||||
* 空 tab 页。
|
||||
*
|
||||
* 陶泥儿 / TapTap 两个目标当前没有内容,但仍是正式 tab(可选中、可来回切),
|
||||
* 不是「点了没反应」。正式导出产物接入后替换掉这个占位。
|
||||
*/
|
||||
export function EmptyTab({ target }: { target: Target }) {
|
||||
return (
|
||||
<div
|
||||
className="grid h-full min-h-0 place-items-center p-6 text-sm text-[#988476]"
|
||||
aria-label={`${TARGET_META[target].label}导出页`}
|
||||
>
|
||||
{`${TARGET_META[target].label}导出页尚未接入`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 全屏外壳。
|
||||
*
|
||||
* 定位由 `export/index.tsx` 的 portal 容器负责,这一层只管版式与配色。
|
||||
* 标题、tab 条、关闭按钮**同一行**:`tabs` 从 header 中间传入,没有 tab 时中间留空。
|
||||
* header 不设纵向内边距,tab 条自己贴住底边,选中指示条正好压在下边框上,像真正的 tab。
|
||||
*
|
||||
* 配色全部用 Tailwind 内联值:`--platform-body-fill` 是渐变、不是颜色,写成
|
||||
* `bg-(--platform-body-fill)` 会解析成空值把背景弄丢,所以这里自带一套暖色梯度。
|
||||
*/
|
||||
export function Shell({
|
||||
children,
|
||||
tabs,
|
||||
onClose,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/** 工作台阶段的 tab 条;目标阶段不传,header 只剩标题与关闭。 */
|
||||
tabs?: ReactNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-gradient-to-b from-[#fffdf9] via-[#fdf9f5] to-[#f8efe7] text-[#3d1f10]">
|
||||
<header className="flex shrink-0 items-stretch gap-3 border-b border-[#e2cbb8] px-4">
|
||||
<div className="flex items-center py-2">
|
||||
<strong className="text-base font-semibold">导出到</strong>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-end justify-center">
|
||||
{tabs}
|
||||
</div>
|
||||
<div className="flex items-center py-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭导出面板"
|
||||
title="关闭"
|
||||
className="grid size-8 shrink-0 place-items-center rounded-lg border border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] transition hover:border-[#c7653d] hover:text-[#c7653d]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PanelState } from '../state/usePanel';
|
||||
import { TAB_PAGES } from '../tabs/registry';
|
||||
|
||||
/**
|
||||
* 只渲染当前激活 tab 的页面组件。
|
||||
*
|
||||
* 非 active 的页面组件会被卸载;它们的状态不在这里,而在面板根的 `usePanel`,
|
||||
* 所以订阅、回合与草稿都不会随卸载丢失。
|
||||
*/
|
||||
export function TabHost({ panel }: { panel: PanelState }) {
|
||||
return <>{TAB_PAGES[panel.activeTarget](panel)}</>;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { type Target, TARGET_META } from '../model';
|
||||
import { TargetIcon } from './TargetIcon';
|
||||
|
||||
/**
|
||||
* 单个导出目标大卡:正方形,**图标是主内容**。
|
||||
*
|
||||
* 图标占卡片大部分面积并居中,标题只作下方的注释行;描述留给正式内容接入后再放,
|
||||
* 现在不抢图标的视觉重量。
|
||||
*/
|
||||
export function TargetCard({
|
||||
target,
|
||||
onOpen,
|
||||
}: {
|
||||
target: Target;
|
||||
onOpen: (target: Target) => void;
|
||||
}) {
|
||||
const meta = TARGET_META[target];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex aspect-square w-full flex-col items-center justify-center gap-4 rounded-3xl border border-[#e2cbb8] bg-[#fffaf5] p-6 text-center shadow-[0_18px_40px_rgba(112,57,30,0.08)] transition hover:-translate-y-0.5 hover:border-[#c7653d] hover:shadow-[0_22px_48px_rgba(112,57,30,0.14)]"
|
||||
onClick={() => onOpen(target)}
|
||||
>
|
||||
<TargetIcon
|
||||
target={target}
|
||||
size={200}
|
||||
className="h-3/5 w-3/5 object-contain transition group-hover:scale-105"
|
||||
/>
|
||||
<span className="text-lg font-semibold text-[#3d1f10]">{meta.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type Target, TARGET_ORDER } from '../model';
|
||||
import { TargetCard } from './TargetCard';
|
||||
|
||||
/**
|
||||
* 首屏三张目标大卡:`Stage.Targets` 阶段的全部内容。
|
||||
*
|
||||
* 卡片是正方形,网格整体在内容槽里居中;窄屏一列、宽屏三列。
|
||||
*/
|
||||
export function TargetGrid({ onOpen }: { onOpen: (target: Target) => void }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 justify-center overflow-auto p-6">
|
||||
<div className="my-auto grid w-full max-w-5xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3">
|
||||
{TARGET_ORDER.map((target) => (
|
||||
<TargetCard key={target} target={target} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import placeholderIcon from '../../../../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import type { Target } from '../model';
|
||||
|
||||
/**
|
||||
* 目标图标占位。
|
||||
*
|
||||
* 三个目标的正式图标还没到,先统一用陶泥儿 IP 图占位;补齐后在这里按 `target` 分支,
|
||||
* 卡片与 tab 都不必改。`className` 交给调用方定尺寸(卡片里是主视觉,tab 里只是小标)。
|
||||
*/
|
||||
export function TargetIcon({
|
||||
target,
|
||||
size = 32,
|
||||
className,
|
||||
}: {
|
||||
target: Target;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src={placeholderIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={size}
|
||||
height={size}
|
||||
data-target={target}
|
||||
className={className ?? 'shrink-0 rounded-lg object-contain'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { type Target, TARGET_META, TARGET_ORDER } from '../model';
|
||||
import { TargetIcon } from './TargetIcon';
|
||||
|
||||
/**
|
||||
* 进入工作台后由卡片收成的顶部 tab 条:三个目标都是正式 tab,可来回切。
|
||||
*
|
||||
* 形态是经典下划线 tab(不是按钮胶囊):小字号、等宽三列、选中项用主色 + 底部指示条,
|
||||
* 视觉重量压在 header 里不抢标题。等宽靠三列 grid,不随标题长短伸缩。
|
||||
*/
|
||||
export function TargetTabs({
|
||||
activeTarget,
|
||||
onSelect,
|
||||
}: {
|
||||
activeTarget: Target;
|
||||
onSelect: (target: Target) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto grid w-full max-w-md grid-cols-3"
|
||||
role="tablist"
|
||||
aria-label="导出目标"
|
||||
>
|
||||
{TARGET_ORDER.map((target) => {
|
||||
const active = target === activeTarget;
|
||||
return (
|
||||
<button
|
||||
key={target}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`relative flex min-w-0 items-center justify-center gap-1.5 px-2 pb-2 pt-1 text-xs font-medium transition-colors after:absolute after:inset-x-3 after:bottom-0 after:h-[2px] after:rounded-full after:transition-colors after:content-[''] ${
|
||||
active
|
||||
? 'text-[#c7653d] after:bg-[#c7653d]'
|
||||
: 'text-[#988476] after:bg-transparent hover:text-[#6f5848]'
|
||||
}`}
|
||||
onClick={() => onSelect(target)}
|
||||
>
|
||||
<TargetIcon
|
||||
target={target}
|
||||
size={20}
|
||||
className="size-5 shrink-0 object-contain"
|
||||
/>
|
||||
<span className="truncate">{TARGET_META[target].label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type {
|
||||
DirectProjectConversationReadGate,
|
||||
DirectProjectConversationWriteGate,
|
||||
} from '../chat/controller/useDirectProjectChatController';
|
||||
import { Shell } from './components/Shell';
|
||||
import { TabHost } from './components/TabHost';
|
||||
import { TargetGrid } from './components/TargetGrid';
|
||||
import { TargetTabs } from './components/TargetTabs';
|
||||
import { Stage } from './model';
|
||||
import { usePanel } from './state/usePanel';
|
||||
|
||||
export type ArtifactsPanelProps = {
|
||||
/** 绑定的项目路径:订阅、历史读取与回合都在这个项目上。 */
|
||||
projectPath: string | null;
|
||||
/**
|
||||
* 项目对话读 / 写权限门:第二订阅读取历史、以及从这里发送回合时都必经这两道门,
|
||||
* 由工作台壳注入,面板不自己判定权限。
|
||||
*/
|
||||
ensureConversationReadAllowed: DirectProjectConversationReadGate;
|
||||
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出产物全屏面板。
|
||||
*
|
||||
* 打开方式与 `view/ui-editor` 同一种:portal 到 `document.body`、`fixed inset-0`。
|
||||
* 面板不接 `open` prop——由外层条件渲染,关闭就是卸载,全部状态随组件销毁,
|
||||
* 生命周期完全本地。
|
||||
*
|
||||
* 首屏是三张目标大卡;点进任一目标后卡片收成顶部 tab,下方只渲染当前 tab。
|
||||
* 每个 tab 的状态都挂在 `usePanel`(面板根)上,页面组件可以随切换卸载而不丢状态。
|
||||
*/
|
||||
export default function ArtifactsPanel({
|
||||
projectPath,
|
||||
ensureConversationReadAllowed,
|
||||
ensureConversationWriteAllowed,
|
||||
onClose,
|
||||
}: ArtifactsPanelProps) {
|
||||
const panel = usePanel({
|
||||
projectPath,
|
||||
ensureConversationReadAllowed,
|
||||
ensureConversationWriteAllowed,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const content = (
|
||||
<Shell
|
||||
onClose={panel.onClose}
|
||||
tabs={
|
||||
panel.stage === Stage.Workspace ? (
|
||||
<TargetTabs
|
||||
activeTarget={panel.activeTarget}
|
||||
onSelect={panel.selectTarget}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{panel.stage === Stage.Targets ? (
|
||||
<TargetGrid onOpen={panel.openTarget} />
|
||||
) : (
|
||||
<TabHost panel={panel} />
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return content;
|
||||
|
||||
return createPortal(
|
||||
// `platform-theme--light` 只为共享组件(工具卡 / 思考块 / 输入区)提供 `--platform-*`
|
||||
// 变量;本面板自己的版式与配色仍全部走 Tailwind 内联值。
|
||||
<div className="platform-theme platform-theme--light fixed inset-0 z-50">
|
||||
<section className="h-full w-full rounded-none p-0 outline-none focus:outline-none">
|
||||
{content}
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 导出产物面板的枚举与目标元数据。
|
||||
*
|
||||
* 面板内一切「阶段 / 目标」身份都走 `enum`,不用字符串字面量或字符串联合:
|
||||
* 字符串身份在 tab 注册表、日志和条件分支之间容易被误拼,`enum` 让漏配在类型层就报错。
|
||||
*/
|
||||
|
||||
/** 面板阶段:先选目标大卡,进入工作台后卡片收成顶部 tab。 */
|
||||
export enum Stage {
|
||||
Targets,
|
||||
Workspace,
|
||||
}
|
||||
|
||||
/** 导出目标:tab 身份、注册表键和条件分支都用它。 */
|
||||
export enum Target {
|
||||
Taonier,
|
||||
Xiaohongshu,
|
||||
TapTap,
|
||||
}
|
||||
|
||||
export type TargetMeta = {
|
||||
target: Target;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/** 卡片与 tab 的展示顺序:新增目标只改这一处。 */
|
||||
export const TARGET_ORDER: readonly Target[] = [
|
||||
Target.Taonier,
|
||||
Target.Xiaohongshu,
|
||||
Target.TapTap,
|
||||
];
|
||||
|
||||
export const TARGET_META: Record<Target, TargetMeta> = {
|
||||
[Target.Taonier]: {
|
||||
target: Target.Taonier,
|
||||
label: '陶泥儿',
|
||||
description: '导出为陶泥儿平台上可在线游玩的游戏',
|
||||
},
|
||||
[Target.Xiaohongshu]: {
|
||||
target: Target.Xiaohongshu,
|
||||
label: '小红书小工具',
|
||||
description: '导出为小红书可嵌入的小工具',
|
||||
},
|
||||
[Target.TapTap]: {
|
||||
target: Target.TapTap,
|
||||
label: 'TapTap H5 小游戏',
|
||||
description: '导出为 TapTap H5 小游戏产物',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type {
|
||||
DirectProjectConversationReadGate,
|
||||
DirectProjectConversationWriteGate,
|
||||
} from '../../chat/controller/useDirectProjectChatController';
|
||||
import { Stage, Target } from '../model';
|
||||
import {
|
||||
useXiaohongshuTab,
|
||||
type XiaohongshuTabState,
|
||||
} from './useXiaohongshuTab';
|
||||
|
||||
export type PanelState = ReturnType<typeof usePanel>;
|
||||
|
||||
/**
|
||||
* 导出产物面板的全部状态。
|
||||
*
|
||||
* 面板层只做两件事:持有「阶段 + 当前 tab」,以及把每个 tab 自己的状态 hook 挂在**这一层**上。
|
||||
* tab 页组件只接收 state 与 action,不自己持状态;切换 tab 时页面组件正常卸载,
|
||||
* 状态留在面板上,所以订阅、回合与草稿都能跨 tab 存活。
|
||||
*/
|
||||
export function usePanel({
|
||||
projectPath,
|
||||
ensureConversationReadAllowed,
|
||||
ensureConversationWriteAllowed,
|
||||
onClose,
|
||||
}: {
|
||||
projectPath: string | null;
|
||||
ensureConversationReadAllowed: DirectProjectConversationReadGate;
|
||||
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [stage, setStage] = useState<Stage>(Stage.Targets);
|
||||
const [activeTarget, setActiveTarget] = useState<Target>(Target.Taonier);
|
||||
/**
|
||||
* 只要进过一次工作台就一直是 `true`:小红书 tab 的订阅一旦建立就跨 tab 保活,
|
||||
* 不因为切到空 tab 而退订(退订再订会换一个新的 subscriber 和游标)。
|
||||
*/
|
||||
const [workspaceEntered, setWorkspaceEntered] = useState(false);
|
||||
|
||||
const openTarget = useCallback((target: Target) => {
|
||||
setActiveTarget(target);
|
||||
setStage(Stage.Workspace);
|
||||
setWorkspaceEntered(true);
|
||||
}, []);
|
||||
|
||||
const selectTarget = useCallback((target: Target) => {
|
||||
setActiveTarget(target);
|
||||
}, []);
|
||||
|
||||
const xiaohongshu: XiaohongshuTabState = useXiaohongshuTab({
|
||||
enabled: workspaceEntered && Boolean(projectPath),
|
||||
projectPath,
|
||||
ensureConversationReadAllowed,
|
||||
ensureConversationWriteAllowed,
|
||||
});
|
||||
|
||||
return {
|
||||
stage,
|
||||
activeTarget,
|
||||
openTarget,
|
||||
selectTarget,
|
||||
onClose,
|
||||
xiaohongshu,
|
||||
};
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
type DirectProjectConversationReadGate,
|
||||
type DirectProjectConversationWriteGate,
|
||||
useDirectProjectChatController,
|
||||
} from '../../chat/controller/useDirectProjectChatController';
|
||||
import { useDirectProjectManifest } from '../../chat/controller/useDirectProjectManifest';
|
||||
import { useDirectProjectTurnStatus } from '../../chat/controller/useDirectProjectTurnStatus';
|
||||
import { buildDirectChatTurns } from '../../chat/conversation/directTurnPresentation';
|
||||
import type { UserContentPart } from '../../chat/generated/UserContentPart';
|
||||
|
||||
export type XiaohongshuTabState = ReturnType<typeof useXiaohongshuTab>;
|
||||
|
||||
/**
|
||||
* 小红书小工具 tab 的全部状态。
|
||||
*
|
||||
* 这个 hook 挂在**面板根**(`usePanel`)上,不挂在 tab 页里:切到别的 tab 时页面组件会卸载,
|
||||
* 但订阅、回合、历史分页和输入草稿必须留在面板上,切回来时还是同一条会话、同一份草稿。
|
||||
*
|
||||
* 订阅走 `useDirectProjectChatController` 内部的 `useDirectThreadChatSubscription`:
|
||||
* 那是同一 thread 的**第二个 subscriber**,有自己的 `subscriptionId` 和游标,不与主聊天互相推进。
|
||||
*/
|
||||
export function useXiaohongshuTab({
|
||||
enabled,
|
||||
projectPath,
|
||||
ensureConversationReadAllowed,
|
||||
ensureConversationWriteAllowed,
|
||||
}: {
|
||||
/** 进过一次工作台且项目路径就绪后才订阅:进入前不产生任何后端访问。 */
|
||||
enabled: boolean;
|
||||
projectPath: string | null;
|
||||
ensureConversationReadAllowed: DirectProjectConversationReadGate;
|
||||
ensureConversationWriteAllowed: DirectProjectConversationWriteGate;
|
||||
}) {
|
||||
const { assets, projectId, refresh, versions } =
|
||||
useDirectProjectManifest(projectPath);
|
||||
const [runtimeNotice, setRuntimeNotice] = useState('');
|
||||
/**
|
||||
* 输入草稿提升到面板层:Composer 随 tab 页卸载后,正文不再随组件一起丢。
|
||||
* 提交成功时 Composer 内部的 `clear()` 会经 `onDraftChange` 把空草稿回执上来。
|
||||
*/
|
||||
const [draft, setDraft] = useState<UserContentPart[]>([]);
|
||||
const chat = useDirectProjectChatController({
|
||||
enabled,
|
||||
ensureConversationReadAllowed,
|
||||
ensureConversationWriteAllowed,
|
||||
onRuntimeError: setRuntimeNotice,
|
||||
projectId,
|
||||
projectPath,
|
||||
refreshManifest: refresh,
|
||||
});
|
||||
const turns = useMemo(
|
||||
() =>
|
||||
buildDirectChatTurns({
|
||||
entries: chat.directEntries,
|
||||
localMessages: chat.localMessages,
|
||||
turnRunning: chat.directTurnRunning,
|
||||
// 运行中回合的起点只在这里给:`turn.started.at` 是宿主的时间,条目上要等收口才盖。
|
||||
turnStartedAt: chat.directTurnStartedAt,
|
||||
}),
|
||||
[
|
||||
chat.directEntries,
|
||||
chat.localMessages,
|
||||
chat.directTurnRunning,
|
||||
chat.directTurnStartedAt,
|
||||
],
|
||||
);
|
||||
const turnStatus = useDirectProjectTurnStatus({
|
||||
turnRunning: chat.directTurnRunning,
|
||||
commandInFlight: chat.commandInFlight,
|
||||
pendingTurns: chat.pendingTurns,
|
||||
turns,
|
||||
});
|
||||
// 运行中回合的读秒起点:只有最新一轮且还没结束才拿实时值,历史回合是 0(不读秒)。
|
||||
const latestTurn = turns.at(-1) ?? null;
|
||||
const activeTurnStartedAt =
|
||||
latestTurn && latestTurn.state !== 'finished' ? latestTurn.startedAt : 0;
|
||||
|
||||
return {
|
||||
projectPath,
|
||||
assets,
|
||||
versions,
|
||||
chat,
|
||||
draft,
|
||||
setDraft,
|
||||
runtimeNotice,
|
||||
turns,
|
||||
turnStatus,
|
||||
activeTurnStartedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Target } from '../model';
|
||||
import type { PanelState } from '../state/usePanel';
|
||||
import { TaonierPage } from './taonier/Page';
|
||||
import { TaptapPage } from './taptap/Page';
|
||||
import { XiaohongshuPage } from './xiaohongshu/Page';
|
||||
|
||||
/**
|
||||
* tab 注册表:`Target` → 该 tab 的渲染函数。
|
||||
*
|
||||
* 用 `Record<Target, …>` 收口,新增目标漏配会在类型层报错;每个 tab 要的状态从面板根
|
||||
* 的 `PanelState` 上取,页面组件本身不持状态。
|
||||
*/
|
||||
export const TAB_PAGES: Record<Target, (panel: PanelState) => ReactNode> = {
|
||||
[Target.Taonier]: () => <TaonierPage />,
|
||||
[Target.Xiaohongshu]: (panel) => <XiaohongshuPage tab={panel.xiaohongshu} />,
|
||||
[Target.TapTap]: () => <TaptapPage />,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { EmptyTab } from '../../components/EmptyTab';
|
||||
import { Target } from '../../model';
|
||||
|
||||
/** 陶泥儿导出 tab:正式产物接入前是空 tab 页。 */
|
||||
export function TaonierPage() {
|
||||
return <EmptyTab target={Target.Taonier} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { EmptyTab } from '../../components/EmptyTab';
|
||||
import { Target } from '../../model';
|
||||
|
||||
/** TapTap H5 小游戏导出 tab:正式产物接入前是空 tab 页。 */
|
||||
export function TaptapPage() {
|
||||
return <EmptyTab target={Target.TapTap} />;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 小红书工作台左半。
|
||||
*
|
||||
* 按需求先留白:后续导出内容落在这里,当前不渲染任何内容,只占住分栏。
|
||||
*/
|
||||
export function ArtifactsPane() {
|
||||
return (
|
||||
<div
|
||||
className="min-h-0 border-[#e2cbb8] bg-[#fffdf9]/40 md:border-r"
|
||||
aria-label="导出内容区"
|
||||
/>
|
||||
);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import type { XiaohongshuTabState } from '../../state/useXiaohongshuTab';
|
||||
import { ArtifactsPane } from './ArtifactsPane';
|
||||
import { ChatPane } from './chat/Pane';
|
||||
|
||||
/**
|
||||
* 小红书小工具工作台:中缝一分为二,左半留白,右半是另一个 chatbox。
|
||||
*
|
||||
* 右半的会话状态不在这里,而在面板根的 `useXiaohongshuTab`:这一页跟着 tab 切走时可以卸载,
|
||||
* 订阅、回合和草稿都留在面板上。
|
||||
*/
|
||||
export function XiaohongshuPage({ tab }: { tab: XiaohongshuTabState }) {
|
||||
return (
|
||||
<div className="grid h-full min-h-0 min-w-0 grid-cols-1 overflow-hidden md:grid-cols-2">
|
||||
<ArtifactsPane />
|
||||
<ChatPane tab={tab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { DirectProjectChatBox } from '../../../../chat/components/DirectProjectChatBox/DirectProjectChatBox';
|
||||
import type { XiaohongshuTabState } from '../../../state/useXiaohongshuTab';
|
||||
|
||||
/**
|
||||
* 右半 chatbox:**原样复用**主聊天的对话盒(`DirectProjectChatBox`)。
|
||||
*
|
||||
* 外壳用 `.game-workbench-chat`:对话盒那套 `.project-chat-surface.is-direct-codex` 几何
|
||||
* 是按这个祖先写的,换成自造容器就会串版。会话真相不在这里——订阅、回合与草稿都在面板根的
|
||||
* `useXiaohongshuTab` 上,这里只是把第二个 subscriber 的投影喂给同一个对话盒。
|
||||
*
|
||||
* 与主聊天的差别只有一处:输入区关掉素材引用(`referencesEnabled={false}`),
|
||||
* 草稿提升到面板层,Composer 随 tab 页卸载再挂回来也不丢字。
|
||||
*/
|
||||
export function ChatPane({ tab }: { tab: XiaohongshuTabState }) {
|
||||
const { chat, turnStatus } = tab;
|
||||
return (
|
||||
<aside
|
||||
className="game-workbench-chat h-full min-h-0"
|
||||
aria-label="小红书小工具导出对话栏"
|
||||
>
|
||||
<DirectProjectChatBox
|
||||
ariaLabel="小红书小工具导出对话"
|
||||
controller={chat}
|
||||
assets={tab.assets}
|
||||
{...(tab.versions ? { versions: tab.versions } : {})}
|
||||
projectPath={tab.projectPath}
|
||||
turns={tab.turns}
|
||||
turnStatus={turnStatus}
|
||||
activeTurnStartedAt={tab.activeTurnStartedAt}
|
||||
statusText={
|
||||
tab.runtimeNotice || chat.statusNotice || '小红书小工具导出会话'
|
||||
}
|
||||
referencesEnabled={false}
|
||||
draft={tab.draft}
|
||||
onDraftChange={(next) => tab.setDraft(next.content)}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 导出产物面板:卡片 → tab 的入口形态与关闭出口。
|
||||
*
|
||||
* 面板状态(阶段 / 当前 tab)在面板根上,本用例只钉住「大卡进入 → 卡片收成 tab → 空 tab 页」
|
||||
* 这条可见链路,不覆盖右侧 chatbox 的订阅行为。
|
||||
*/
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ArtifactsPanel from '../src/view/project-development/export';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function renderPanel() {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ArtifactsPanel
|
||||
projectPath={null}
|
||||
ensureConversationReadAllowed={async () => true}
|
||||
ensureConversationWriteAllowed={async () => true}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
describe('导出产物面板', () => {
|
||||
it('首屏用大卡片展示三个导出目标', () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText('陶泥儿')).not.toBeNull();
|
||||
expect(screen.getByText('小红书小工具')).not.toBeNull();
|
||||
expect(screen.getByText('TapTap H5 小游戏')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('点进目标后卡片收成顶部 tab,切到空目标只换 tab 页', () => {
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /陶泥儿/u }));
|
||||
expect(screen.getByRole('tablist', { name: '导出目标' })).not.toBeNull();
|
||||
expect(screen.getByText('陶泥儿导出页尚未接入')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /TapTap H5 小游戏/u }));
|
||||
expect(screen.getByText('TapTap H5 小游戏导出页尚未接入')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('小红书 tab 挂载的是主聊天同一个对话盒', () => {
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /小红书小工具/u }));
|
||||
|
||||
const surface = document.querySelector(
|
||||
'.game-workbench-chat .project-chat-surface.is-direct-codex',
|
||||
);
|
||||
expect(surface).not.toBeNull();
|
||||
expect(surface?.getAttribute('aria-label')).toBe('小红书小工具导出对话');
|
||||
// 对话盒里就是主聊天那套「顶栏 / 消息流 / 输入区」,不是另一份自造版式。
|
||||
expect(surface?.querySelector('.project-chat-conversation')).not.toBeNull();
|
||||
expect(surface?.querySelector('.project-chat-composer')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('关闭按钮把销毁交给壳层', () => {
|
||||
const { onClose } = renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭导出面板' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user