合并 master 最新更新
合入 master 的 BgFilter、CI、运维与现役平台改造。 保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。 修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。 同步冲突文档、锁文件和开发脚本。
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useAuthUi } from './components/auth/AuthUiContext';
|
||||
import type { SelectionStage } from './components/platform-entry/platformEntryActiveTypes';
|
||||
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
|
||||
import { useHostNavigationCanGoBack } from './hooks/useHostNavigationCanGoBack';
|
||||
import {
|
||||
isAppHistoryState,
|
||||
isKnownMainAppPagePath,
|
||||
normalizeAppPath,
|
||||
pushAppHistoryPath,
|
||||
replaceAppHistoryPath,
|
||||
resolveInitialSelectionStageFromPath,
|
||||
resolvePathForSelectionStage,
|
||||
shouldRedirectEditorCanvasWithoutProject,
|
||||
} from './routing/activeAppPageRoutes';
|
||||
import {
|
||||
resolveAppTitleForSelectionStage,
|
||||
syncAppTitle,
|
||||
} from './services/activeAppTitle';
|
||||
import {
|
||||
refreshNativeAppHostRuntime,
|
||||
subscribeHostRuntimeChange,
|
||||
} from './services/host-bridge/hostBridge';
|
||||
|
||||
function resolveInitialAppSelectionStage() {
|
||||
if (
|
||||
shouldRedirectEditorCanvasWithoutProject(
|
||||
window.location.pathname,
|
||||
window.location.search,
|
||||
)
|
||||
) {
|
||||
replaceAppHistoryPath('/project');
|
||||
return 'project';
|
||||
}
|
||||
|
||||
if (!isKnownMainAppPagePath(window.location.pathname)) {
|
||||
replaceAppHistoryPath('/');
|
||||
return 'platform';
|
||||
}
|
||||
|
||||
return resolveInitialSelectionStageFromPath(window.location.pathname, false);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const authUi = useAuthUi();
|
||||
const hostNavigation = useHostNavigationCanGoBack();
|
||||
const [, setHostRuntimeRevision] = useState(0);
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(
|
||||
resolveInitialAppSelectionStage,
|
||||
);
|
||||
|
||||
const setSelectionStage = useCallback(
|
||||
(stage: SelectionStage, options?: { path?: string }) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(options?.path ?? resolvePathForSelectionStage(stage));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeHostRuntimeChange(() => {
|
||||
setHostRuntimeRevision((revision) => revision + 1);
|
||||
});
|
||||
|
||||
void refreshNativeAppHostRuntime();
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncStageFromHistory = () => {
|
||||
if (
|
||||
shouldRedirectEditorCanvasWithoutProject(
|
||||
window.location.pathname,
|
||||
window.location.search,
|
||||
)
|
||||
) {
|
||||
replaceAppHistoryPath('/project');
|
||||
setRawSelectionStage('project');
|
||||
return;
|
||||
}
|
||||
if (!isKnownMainAppPagePath(window.location.pathname)) {
|
||||
replaceAppHistoryPath('/');
|
||||
setRawSelectionStage('platform');
|
||||
return;
|
||||
}
|
||||
setRawSelectionStage(
|
||||
resolveInitialSelectionStageFromPath(window.location.pathname, false),
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', syncStageFromHistory);
|
||||
return () => window.removeEventListener('popstate', syncStageFromHistory);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hostNavigation.isSupported ||
|
||||
hostNavigation.canGoBack ||
|
||||
selectionStage === 'platform' ||
|
||||
isAppHistoryState(window.history.state)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = normalizeAppPath(window.location.pathname);
|
||||
const currentSearch = window.location.search;
|
||||
|
||||
replaceAppHistoryPath('/');
|
||||
pushAppHistoryPath(`${currentPath}${currentSearch}`);
|
||||
}, [hostNavigation.canGoBack, hostNavigation.isSupported, selectionStage]);
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const isImageEditorStage = selectionStage === 'image-editor';
|
||||
const platformShellSurfaceClass = isImageEditorStage
|
||||
? 'bg-white p-0'
|
||||
: 'bg-[image:var(--platform-body-fill)] p-2 sm:p-4';
|
||||
|
||||
useEffect(() => {
|
||||
syncAppTitle(resolveAppTitleForSelectionStage(selectionStage));
|
||||
}, [selectionStage]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden ${platformShellSurfaceClass} font-sans text-[var(--platform-text-strong)]`}
|
||||
>
|
||||
<PlatformEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import App from './App';
|
||||
import App from './ActiveApp';
|
||||
import { AuthGate } from './components/auth/AuthGate';
|
||||
|
||||
export default function AuthenticatedApp() {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import './index.css';
|
||||
|
||||
import { StrictMode, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { FloatingFeedbackEntry } from './components/common/FloatingFeedbackEntry';
|
||||
import { stabilizeMobileViewportKeyboardFocus } from './mobileViewportKeyboardFocus';
|
||||
import { lockMobileViewportZoom } from './mobileViewportZoomLock';
|
||||
import { resolveAppRoute } from './routing/activeAppRoutes';
|
||||
import {
|
||||
getHostRuntime,
|
||||
refreshNativeAppHostRuntime,
|
||||
} from './services/host-bridge/hostBridge';
|
||||
|
||||
type AppRoot = ReturnType<typeof createRoot>;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__tavernRealmsRoot__?: AppRoot;
|
||||
}
|
||||
}
|
||||
|
||||
const route = resolveAppRoute(window.location.pathname);
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Missing #root container');
|
||||
}
|
||||
|
||||
function markWechatMiniProgramRuntime() {
|
||||
if (getHostRuntime().kind === 'wechat_mini_program') {
|
||||
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
const root = (window.__tavernRealmsRoot__ ??= createRoot(rootElement));
|
||||
const RouteComponent = route.Component;
|
||||
const routeElement = <RouteComponent />;
|
||||
|
||||
lockMobileViewportZoom();
|
||||
stabilizeMobileViewportKeyboardFocus();
|
||||
markWechatMiniProgramRuntime();
|
||||
void refreshNativeAppHostRuntime();
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<Suspense fallback={null}>{routeElement}</Suspense>
|
||||
<FloatingFeedbackEntry />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -39,10 +39,9 @@ const authMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../services/apiClient')>(
|
||||
'../../services/apiClient',
|
||||
);
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/apiClient')
|
||||
>('../../services/apiClient');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
@@ -480,9 +479,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
const phoneInput = within(dialog).getByLabelText(
|
||||
'手机号',
|
||||
) as HTMLInputElement;
|
||||
const codeInput = within(dialog).getByLabelText(
|
||||
'验证码',
|
||||
) as HTMLInputElement;
|
||||
const codeInput = within(dialog).getByLabelText('验证码') as HTMLInputElement;
|
||||
expect(phoneInput.className).toContain('platform-text-field');
|
||||
expect(codeInput.className).toContain('platform-text-field');
|
||||
|
||||
@@ -937,6 +934,28 @@ test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate shows mainland China phone validation errors', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.sendPhoneLoginCode.mockRejectedValueOnce(
|
||||
new Error('仅支持中国大陆手机号(+86)'),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<ProtectedActionButton onAuthenticated={vi.fn()} />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
await user.type(within(dialog).getByLabelText('手机号'), '+12025550123');
|
||||
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText('仅支持中国大陆手机号(+86)'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('login modal resets draft state every time it is reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -44,12 +44,12 @@ test('绑定手机号表单展示当前身份并提交手机号验证码', async
|
||||
|
||||
expect(screen.getByText('手机号')).toBeTruthy();
|
||||
expect(screen.getByText('当前登录身份:微信旅人')).toBeTruthy();
|
||||
expect(screen.getByText('AI 美术创作平台')).toBeTruthy();
|
||||
expect(screen.queryByText('视觉叙事 RPG')).toBeNull();
|
||||
|
||||
await user.type(phoneInput, '13800000000');
|
||||
await user.type(codeInput, '123456');
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '绑定手机号并进入游戏' }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '绑定手机号并继续' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('13800000000', '123456');
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { BRAND_ASSETS } from '../../uiAssets';
|
||||
import type {
|
||||
AuthCaptchaChallenge,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PLATFORM_BRAND_ASSETS } from '../common/platformBrandAssets';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
@@ -63,14 +66,16 @@ export function BindPhoneScreen({
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
return (
|
||||
<div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}
|
||||
>
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(204,117,76,0.18),rgba(240,203,169,0.16))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__lockup">
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierProductIp}
|
||||
src={PLATFORM_BRAND_ASSETS.taonierProductIp}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
@@ -78,7 +83,9 @@ export function BindPhoneScreen({
|
||||
/>
|
||||
<div className="selection-hero-brand__title">陶泥儿</div>
|
||||
</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
<div className="selection-hero-brand__subtitle">
|
||||
AI 美术创作平台
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
|
||||
账号激活
|
||||
@@ -87,7 +94,7 @@ export function BindPhoneScreen({
|
||||
绑定手机号
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-[var(--platform-text-base)]">
|
||||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。
|
||||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步账号资料。
|
||||
</p>
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
@@ -186,7 +193,7 @@ export function BindPhoneScreen({
|
||||
disabled={binding || !phone.trim() || !code.trim()}
|
||||
size="lg"
|
||||
>
|
||||
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
|
||||
{binding ? '正在绑定...' : '绑定手机号并继续'}
|
||||
</PlatformActionButton>
|
||||
|
||||
<PlatformActionButton
|
||||
|
||||
@@ -530,7 +530,9 @@ function PhoneCodeForm({
|
||||
tone="secondary"
|
||||
size="lg"
|
||||
className="shrink-0 text-sm"
|
||||
onClick={() => void onSendCode()}
|
||||
onClick={() => {
|
||||
void onSendCode().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中'
|
||||
@@ -624,7 +626,9 @@ function PasswordResetPanel({
|
||||
tone="secondary"
|
||||
size="lg"
|
||||
className="shrink-0 text-sm"
|
||||
onClick={() => void onSendCode()}
|
||||
onClick={() => {
|
||||
void onSendCode().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中'
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { BRAND_ASSETS } from '../../uiAssets';
|
||||
import {
|
||||
clampFloatingFeedbackPosition,
|
||||
FLOATING_FEEDBACK_DESKTOP_QUERY,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
resolveFloatingFeedbackContactPanelOffset,
|
||||
resolveInitialFloatingFeedbackPosition,
|
||||
} from './floatingFeedbackEntryModel';
|
||||
import { PLATFORM_BRAND_ASSETS } from './platformBrandAssets';
|
||||
|
||||
type FloatingFeedbackDragState = {
|
||||
pointerId: number;
|
||||
@@ -45,8 +45,7 @@ function isSameFloatingFeedbackPointer(
|
||||
|
||||
function shouldUseMouseDragFallback() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.PointerEvent !== 'function'
|
||||
typeof window !== 'undefined' && typeof window.PointerEvent !== 'function'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -295,12 +294,12 @@ export function FloatingFeedbackEntry() {
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierFeedbackQq}
|
||||
src={PLATFORM_BRAND_ASSETS.taonierFeedbackQq}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierFeedbackWechat}
|
||||
src={PLATFORM_BRAND_ASSETS.taonierFeedbackWechat}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
@@ -324,7 +323,7 @@ export function FloatingFeedbackEntry() {
|
||||
onClick={openFeedbackForm}
|
||||
>
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierFeedbackEntry}
|
||||
src={PLATFORM_BRAND_ASSETS.taonierFeedbackEntry}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
|
||||
@@ -17,7 +17,6 @@ type PlatformDangerConfirmDialogProps = {
|
||||
showCloseButton?: boolean;
|
||||
portal?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
variant?: 'platform' | 'pixel';
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
footerClassName?: string;
|
||||
@@ -43,7 +42,6 @@ export function PlatformDangerConfirmDialog({
|
||||
showCloseButton = true,
|
||||
portal = true,
|
||||
size = 'sm',
|
||||
variant = 'platform',
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
footerClassName,
|
||||
@@ -66,7 +64,6 @@ export function PlatformDangerConfirmDialog({
|
||||
confirmTone="danger"
|
||||
portal={portal}
|
||||
size={size}
|
||||
variant={variant}
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
footerClassName={footerClassName}
|
||||
|
||||
@@ -11,7 +11,6 @@ type PlatformModalCloseButtonVariant =
|
||||
| 'floating'
|
||||
| 'floatingPlain'
|
||||
| 'platformIcon'
|
||||
| 'pixel'
|
||||
| 'editorDark';
|
||||
|
||||
type PlatformModalCloseButtonPlacement = 'absolute' | 'inline';
|
||||
@@ -39,21 +38,12 @@ const PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT: Record<
|
||||
'absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm',
|
||||
floatingPlain:
|
||||
'absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]',
|
||||
platformIcon: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45',
|
||||
pixel:
|
||||
'flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/30 p-0 text-zinc-400 shadow-[0_8px_18px_rgba(0,0,0,0.28)] transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/70 disabled:cursor-not-allowed disabled:opacity-45',
|
||||
platformIcon:
|
||||
'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45',
|
||||
editorDark:
|
||||
'platform-modal-close-button--editor-dark rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white',
|
||||
};
|
||||
|
||||
const PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT: Record<
|
||||
PlatformModalCloseButtonPlacement,
|
||||
string
|
||||
> = {
|
||||
absolute: 'absolute right-4 top-3 sm:right-5 sm:top-4',
|
||||
inline: 'relative shrink-0',
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台弹窗关闭按钮。
|
||||
* 收口个人中心和平台浮层里重复的关闭 aria、尺寸和视觉样式。
|
||||
@@ -88,11 +78,7 @@ export function PlatformModalCloseButton({
|
||||
onClick={handleClick}
|
||||
className={[
|
||||
PLATFORM_MODAL_CLOSE_BUTTON_CLASS_BY_VARIANT[variant],
|
||||
variant === 'pixel'
|
||||
? PLATFORM_MODAL_CLOSE_BUTTON_PIXEL_PLACEMENT_CLASS_BY_PLACEMENT[
|
||||
placement
|
||||
]
|
||||
: null,
|
||||
placement === 'inline' ? 'relative shrink-0' : null,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { PlatformActionButton } from './PlatformActionButton';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
type UnifiedConfirmDialogTone = 'primary' | 'danger';
|
||||
type UnifiedConfirmDialogVariant = 'platform' | 'pixel';
|
||||
|
||||
type UnifiedConfirmDialogProps = {
|
||||
open: boolean;
|
||||
@@ -25,7 +24,6 @@ type UnifiedConfirmDialogProps = {
|
||||
closeOnBackdrop?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
portal?: boolean;
|
||||
variant?: UnifiedConfirmDialogVariant;
|
||||
size?: 'sm' | 'md';
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
@@ -56,7 +54,6 @@ export function UnifiedConfirmDialog({
|
||||
closeOnBackdrop = true,
|
||||
showCloseButton = true,
|
||||
portal = true,
|
||||
variant = 'platform',
|
||||
size = 'sm',
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
@@ -77,7 +74,6 @@ export function UnifiedConfirmDialog({
|
||||
closeOnBackdrop={closeOnBackdrop && !busy}
|
||||
showCloseButton={showCloseButton}
|
||||
portal={portal}
|
||||
variant={variant}
|
||||
size={size}
|
||||
overlayClassName={overlayClassName}
|
||||
panelClassName={panelClassName}
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
|
||||
|
||||
type UnifiedModalVariant = 'platform' | 'pixel';
|
||||
type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
|
||||
type UnifiedModalCloseVariant = NonNullable<
|
||||
ComponentProps<typeof PlatformModalCloseButton>['variant']
|
||||
@@ -29,7 +27,6 @@ type UnifiedModalProps = {
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
onClose: () => void;
|
||||
variant?: UnifiedModalVariant;
|
||||
size?: UnifiedModalSize;
|
||||
showHeader?: boolean;
|
||||
closeDisabled?: boolean;
|
||||
@@ -60,30 +57,10 @@ const PLATFORM_SIZE_CLASS: Record<UnifiedModalSize, string> = {
|
||||
fullscreen: 'max-w-[min(100vw,76rem)] sm:h-[min(92vh,60rem)]',
|
||||
};
|
||||
|
||||
const PIXEL_SIZE_CLASS: Record<UnifiedModalSize, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
fullscreen: 'max-w-[min(96vw,64rem)]',
|
||||
};
|
||||
|
||||
function joinClassNames(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function getPanelStyle(
|
||||
variant: UnifiedModalVariant,
|
||||
panelStyle: CSSProperties | undefined,
|
||||
function joinClassNames(
|
||||
...classNames: Array<string | false | null | undefined>
|
||||
) {
|
||||
if (variant !== 'pixel') {
|
||||
return panelStyle;
|
||||
}
|
||||
|
||||
return {
|
||||
...getNineSliceStyle(UI_CHROME.modalPanel),
|
||||
...panelStyle,
|
||||
};
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function UnifiedModalContent({
|
||||
@@ -95,7 +72,6 @@ function UnifiedModalContent({
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
variant = 'platform',
|
||||
size = 'md',
|
||||
showHeader = true,
|
||||
closeDisabled = false,
|
||||
@@ -140,42 +116,29 @@ function UnifiedModalContent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPixel = variant === 'pixel';
|
||||
const sizeClassName = isPixel
|
||||
? PIXEL_SIZE_CLASS[size]
|
||||
: PLATFORM_SIZE_CLASS[size];
|
||||
|
||||
const overlayClasses = isPixel
|
||||
? 'fixed inset-0 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4'
|
||||
: 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4';
|
||||
|
||||
const panelClasses = isPixel
|
||||
? 'pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]'
|
||||
: 'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]';
|
||||
|
||||
const headerClasses = isPixel
|
||||
? 'flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4'
|
||||
: 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
|
||||
|
||||
const titleClasses = isPixel
|
||||
? 'truncate text-sm font-semibold text-white'
|
||||
: 'text-base font-semibold text-[var(--platform-text-strong)]';
|
||||
|
||||
const descriptionClasses = isPixel
|
||||
? 'mt-1 text-xs leading-5 text-zinc-400'
|
||||
: 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]';
|
||||
|
||||
const bodyClasses = isPixel
|
||||
? 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5'
|
||||
: 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5';
|
||||
|
||||
const footerClasses = isPixel
|
||||
? 'flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4'
|
||||
: 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
|
||||
const sizeClassName = PLATFORM_SIZE_CLASS[size];
|
||||
const overlayClasses =
|
||||
'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4';
|
||||
const panelClasses =
|
||||
'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]';
|
||||
const headerClasses =
|
||||
'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
|
||||
const titleClasses =
|
||||
'text-base font-semibold text-[var(--platform-text-strong)]';
|
||||
const descriptionClasses =
|
||||
'mt-1 text-xs leading-5 text-[var(--platform-text-base)]';
|
||||
const bodyClasses =
|
||||
'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5';
|
||||
const footerClasses =
|
||||
'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
|
||||
className={joinClassNames(
|
||||
overlayClasses,
|
||||
zIndexClassName,
|
||||
overlayClassName,
|
||||
)}
|
||||
style={overlayStyle}
|
||||
onPointerDownCapture={(event) => {
|
||||
backdropPointerSequenceRef.current =
|
||||
@@ -210,7 +173,7 @@ function UnifiedModalContent({
|
||||
aria-label={ariaLabel ?? (!showHeader ? title : undefined)}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
|
||||
style={getPanelStyle(variant, panelStyle)}
|
||||
style={panelStyle}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{showHeader ? (
|
||||
@@ -239,7 +202,7 @@ function UnifiedModalContent({
|
||||
label={closeLabel}
|
||||
onClick={onClose}
|
||||
disabled={closeDisabled}
|
||||
variant={closeVariant ?? (isPixel ? 'pixel' : 'platformIcon')}
|
||||
variant={closeVariant ?? 'platformIcon'}
|
||||
icon={closeIcon}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const PLATFORM_BRAND_ASSETS = {
|
||||
taonierProductIp: '/branding/taonier-product-ip.png',
|
||||
taonierFeedbackEntry: '/branding/taonier-feedback-entry.png',
|
||||
taonierFeedbackQq: '/branding/taonier-feedback-qq.png',
|
||||
taonierFeedbackWechat: '/branding/taonier-feedback-wechat.png',
|
||||
} as const;
|
||||
@@ -166,9 +166,7 @@ describe('CreationLandingView', () => {
|
||||
expect(
|
||||
screen.getByText('陶泥儿 Genarrative|游戏美术 AI 创作工具'),
|
||||
).toBeTruthy();
|
||||
const subtitle = screen.getByText(
|
||||
/面向个人创作者的游戏美术 AI 工作台/u,
|
||||
);
|
||||
const subtitle = screen.getByText(/面向个人创作者的游戏美术 AI 工作台/u);
|
||||
expect(subtitle.textContent).toContain('美术 Agent');
|
||||
expect(subtitle.textContent).toContain('无限画布');
|
||||
expect(subtitle.textContent).toContain('角色、场景、UI 与宣发素材');
|
||||
@@ -222,6 +220,73 @@ describe('CreationLandingView', () => {
|
||||
expect(onOpenProject).toHaveBeenCalledWith('project-newest');
|
||||
});
|
||||
|
||||
it('filters recent projects by the shared search keyword', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
searchKeyword="最新"
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('最新项目')).toBeTruthy();
|
||||
expect(screen.queryByText('旧项目')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['森林角色', '森林角色', '海边场景'],
|
||||
['阿蓝', '海边场景', '森林角色'],
|
||||
['金色盔甲', '森林角色', '海边场景'],
|
||||
])(
|
||||
'filters featured assets by search keyword %s',
|
||||
async (searchKeyword, expectedLabel, hiddenLabel) => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce([]);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: 'forest-character',
|
||||
projectId: 'project-newest',
|
||||
label: '森林角色',
|
||||
imageSrc: '/generated-editor-images/forest-character.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '金色盔甲',
|
||||
authorDisplayName: '阿绿',
|
||||
publicShowcaseEnabled: true,
|
||||
},
|
||||
{
|
||||
resourceId: 'seaside-scene',
|
||||
projectId: 'project-newest',
|
||||
label: '海边场景',
|
||||
imageSrc: '/generated-editor-images/seaside-scene.png',
|
||||
width: 512,
|
||||
height: 512,
|
||||
sourceType: 'generated',
|
||||
prompt: '夜晚月光',
|
||||
authorDisplayName: '阿蓝',
|
||||
publicShowcaseEnabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<CreationLandingView
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenProjects={vi.fn()}
|
||||
searchKeyword={searchKeyword}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(expectedLabel)).toBeTruthy();
|
||||
expect(screen.queryByText(hiddenLabel)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('hides recent projects and opens login when an anonymous user starts creation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const openLoginModal = vi.fn();
|
||||
@@ -405,7 +470,7 @@ describe('CreationLandingView', () => {
|
||||
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('likes a featured asset from the waterfall card action', async () => {
|
||||
it('likes a featured asset from the list card action', async () => {
|
||||
const user = userEvent.setup();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
@@ -619,7 +684,7 @@ describe('CreationLandingView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => {
|
||||
it('uses Taonier featured as the generated project resource list instead of creation entries', async () => {
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -671,9 +736,13 @@ describe('CreationLandingView', () => {
|
||||
expect(await screen.findByText('精选素材 A')).toBeTruthy();
|
||||
expect(screen.getByText('精选素材 B')).toBeTruthy();
|
||||
expect(screen.queryByText('上传素材')).toBeNull();
|
||||
expect(screen.getByLabelText('用户素材瀑布流').className).toContain(
|
||||
const showcaseList = screen.getByRole('list', {
|
||||
name: '陶泥儿精选素材列表',
|
||||
});
|
||||
expect(showcaseList.className).toContain(
|
||||
'creation-landing__asset-waterfall',
|
||||
);
|
||||
expect(within(showcaseList).getAllByRole('listitem')).toHaveLength(2);
|
||||
const firstPreview = container.querySelector(
|
||||
'.creation-landing__asset-preview',
|
||||
) as HTMLElement | null;
|
||||
@@ -686,6 +755,22 @@ describe('CreationLandingView', () => {
|
||||
});
|
||||
|
||||
it('renders campaign card previews with centered contain media', async () => {
|
||||
const campaignObjectKey =
|
||||
'generated-character-drafts/editor/showcase-campaign/current/campaign.png';
|
||||
const signedCampaignUrl = 'data:image/png;base64,Y2FtcGFpZ24=';
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
read: {
|
||||
objectKey: campaignObjectKey,
|
||||
signedUrl: signedCampaignUrl,
|
||||
expiresAt: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
listEditorProjectsMock.mockResolvedValue(projectItems);
|
||||
listPublicEditorProjectResourcesMock.mockResolvedValue({
|
||||
resources: [],
|
||||
@@ -694,6 +779,7 @@ describe('CreationLandingView', () => {
|
||||
enabled: true,
|
||||
title: '活动精选',
|
||||
imageSrc: '/campaign.png',
|
||||
imageObjectKey: campaignObjectKey,
|
||||
imageWidth: 900,
|
||||
imageHeight: 1200,
|
||||
prompt: '活动提示词',
|
||||
@@ -712,9 +798,17 @@ describe('CreationLandingView', () => {
|
||||
'creation-landing__asset-preview--campaign',
|
||||
);
|
||||
expect(campaignPreview?.style.aspectRatio).toBe('900 / 1200');
|
||||
const campaignImage = await screen.findByRole('img', {
|
||||
name: '活动精选',
|
||||
});
|
||||
expect((campaignImage as HTMLImageElement).src).toBe(signedCampaignUrl);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`/api/assets/read-url?objectKey=${encodeURIComponent(campaignObjectKey)}`,
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads more featured resources when the waterfall reaches the end', async () => {
|
||||
it('loads more featured resources when the list reaches the end', async () => {
|
||||
const observers = installIntersectionObserverMock();
|
||||
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
|
||||
listPublicEditorProjectResourcesMock
|
||||
@@ -808,10 +902,10 @@ describe('CreationLandingView', () => {
|
||||
await screen.findByRole('button', { name: '读取更多素材失败,点击重试' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('精选素材 A')).toBeTruthy();
|
||||
expect(screen.getByLabelText('用户素材瀑布流')).toBeTruthy();
|
||||
expect(screen.getByLabelText('陶泥儿精选素材列表')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the featured waterfall empty when project resources are empty', async () => {
|
||||
it('keeps the featured list empty when project resources are empty', async () => {
|
||||
renderCreationLanding({
|
||||
authValue: createAuthValue({
|
||||
user: null,
|
||||
@@ -820,7 +914,7 @@ describe('CreationLandingView', () => {
|
||||
});
|
||||
|
||||
expect(await screen.findByText('暂无素材')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('用户素材瀑布流')).toBeNull();
|
||||
expect(screen.queryByLabelText('陶泥儿精选素材列表')).toBeNull();
|
||||
expect(screen.queryByText('精选入口')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Film, Image as ImageIcon, Music2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
@@ -30,6 +37,10 @@ import {
|
||||
type ShowcaseAssetPreview,
|
||||
type ShowcaseTabId,
|
||||
} from './creationShowcaseModel';
|
||||
import {
|
||||
applySequentialShowcaseMasonry,
|
||||
resetSequentialShowcaseMasonry,
|
||||
} from './showcaseMasonryLayout';
|
||||
|
||||
type CreationLandingViewProps = {
|
||||
onOpenProject: (
|
||||
@@ -38,6 +49,7 @@ type CreationLandingViewProps = {
|
||||
) => void;
|
||||
onOpenProjects: () => void;
|
||||
onOpenCommunity?: () => void;
|
||||
searchKeyword?: string;
|
||||
};
|
||||
|
||||
type CreationFeatureTool =
|
||||
@@ -170,6 +182,59 @@ function getPreviewAspectRatio(preview: ShowcaseAssetPreview) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function useSequentialShowcaseMasonry(
|
||||
itemOrderKey: string,
|
||||
expectedItemCount: number,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = container.ownerDocument.defaultView;
|
||||
let animationFrame: number | null = null;
|
||||
const layout = () => {
|
||||
animationFrame = null;
|
||||
applySequentialShowcaseMasonry(container, { expectedItemCount });
|
||||
};
|
||||
const scheduleLayout = () => {
|
||||
if (animationFrame !== null) {
|
||||
return;
|
||||
}
|
||||
if (view?.requestAnimationFrame) {
|
||||
animationFrame = view.requestAnimationFrame(layout);
|
||||
return;
|
||||
}
|
||||
layout();
|
||||
};
|
||||
|
||||
layout();
|
||||
const ResizeObserverConstructor = globalThis.ResizeObserver;
|
||||
const resizeObserver = ResizeObserverConstructor
|
||||
? new ResizeObserverConstructor(scheduleLayout)
|
||||
: null;
|
||||
resizeObserver?.observe(container);
|
||||
for (const card of container.querySelectorAll<HTMLElement>(
|
||||
':scope > .creation-landing__asset-card',
|
||||
)) {
|
||||
resizeObserver?.observe(card);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationFrame !== null && view?.cancelAnimationFrame) {
|
||||
view.cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
resetSequentialShowcaseMasonry(container);
|
||||
};
|
||||
}, [expectedItemCount, itemOrderKey]);
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
|
||||
function getPreviewIcon(preview: ShowcaseAssetPreview) {
|
||||
if (preview.mediaType === 'audio') {
|
||||
return <Music2 aria-hidden="true" />;
|
||||
@@ -424,9 +489,11 @@ export function CreationLandingView({
|
||||
onOpenProject,
|
||||
onOpenProjects,
|
||||
onOpenCommunity,
|
||||
searchKeyword = '',
|
||||
}: CreationLandingViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
|
||||
const [recentProjects, setRecentProjects] = useState<EditorProjectSnapshot[]>(
|
||||
[],
|
||||
);
|
||||
@@ -734,6 +801,31 @@ export function CreationLandingView({
|
||||
}),
|
||||
[activeShowcaseTab, projectShowcaseResources, showcaseCampaign],
|
||||
);
|
||||
const visibleShowcaseItems = useMemo(() => {
|
||||
if (!normalizedSearchKeyword) {
|
||||
return showcaseItems;
|
||||
}
|
||||
return showcaseItems.filter((item) =>
|
||||
[item.label, item.author, item.prompt].some((value) =>
|
||||
value.toLocaleLowerCase().includes(normalizedSearchKeyword),
|
||||
),
|
||||
);
|
||||
}, [normalizedSearchKeyword, showcaseItems]);
|
||||
const showcaseMasonryOrderKey = JSON.stringify(
|
||||
visibleShowcaseItems.map((item) => item.id),
|
||||
);
|
||||
const showcaseMasonryRef = useSequentialShowcaseMasonry(
|
||||
showcaseMasonryOrderKey,
|
||||
visibleShowcaseItems.length,
|
||||
);
|
||||
const visibleRecentProjects = useMemo(() => {
|
||||
if (!normalizedSearchKeyword) {
|
||||
return recentProjects;
|
||||
}
|
||||
return recentProjects.filter((project) =>
|
||||
project.title.toLocaleLowerCase().includes(normalizedSearchKeyword),
|
||||
);
|
||||
}, [normalizedSearchKeyword, recentProjects]);
|
||||
|
||||
const isShowcaseLoading = isLoadingShowcase;
|
||||
const showcaseEmptyText = '暂无素材';
|
||||
@@ -772,13 +864,15 @@ export function CreationLandingView({
|
||||
</PlatformEmptyState>
|
||||
);
|
||||
}
|
||||
if (showcaseItems.length === 0) {
|
||||
if (visibleShowcaseItems.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<PlatformEmptyState surface="subpanel" size="inline">
|
||||
{showcaseNextCursor || isLoadingMoreShowcase
|
||||
? '正在读取更多素材'
|
||||
: showcaseEmptyText}
|
||||
: normalizedSearchKeyword
|
||||
? '没有匹配素材'
|
||||
: showcaseEmptyText}
|
||||
</PlatformEmptyState>
|
||||
{renderShowcaseLoadMoreSentinel()}
|
||||
</>
|
||||
@@ -788,10 +882,12 @@ export function CreationLandingView({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={showcaseMasonryRef}
|
||||
className="creation-landing__asset-waterfall"
|
||||
aria-label="用户素材瀑布流"
|
||||
aria-label="陶泥儿精选素材列表"
|
||||
role="list"
|
||||
>
|
||||
{showcaseItems.map((item) => {
|
||||
{visibleShowcaseItems.map((item) => {
|
||||
const showcaseId = item.showcaseId?.trim();
|
||||
const isLiked = Boolean(
|
||||
showcaseId && likedShowcaseIds.has(showcaseId),
|
||||
@@ -800,7 +896,11 @@ export function CreationLandingView({
|
||||
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
|
||||
);
|
||||
return (
|
||||
<article key={item.id} className="creation-landing__asset-card">
|
||||
<article
|
||||
key={item.id}
|
||||
className="creation-landing__asset-card"
|
||||
role="listitem"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="creation-landing__asset-card-open"
|
||||
@@ -994,7 +1094,7 @@ export function CreationLandingView({
|
||||
正在读取项目
|
||||
</PlatformEmptyState>
|
||||
) : (
|
||||
recentProjects.map((project) => {
|
||||
visibleRecentProjects.map((project) => {
|
||||
const coverSnapshot =
|
||||
resolveProjectCoverSnapshotResource(project);
|
||||
return (
|
||||
@@ -1091,7 +1191,7 @@ export function CreationLandingView({
|
||||
}
|
||||
>
|
||||
<img
|
||||
src="/branding/mobile-home-welcome-taonier-ip.png"
|
||||
src="/branding/taonier-product-ip.png"
|
||||
alt=""
|
||||
className="platform-mobile-home-welcome-dialog__icon"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applySequentialShowcaseMasonry,
|
||||
createSequentialShowcaseMasonryLayout,
|
||||
resetSequentialShowcaseMasonry,
|
||||
resolveShowcaseMasonryColumnCount,
|
||||
} from './showcaseMasonryLayout';
|
||||
|
||||
function createRect(width: number, height: number): DOMRect {
|
||||
return {
|
||||
bottom: height,
|
||||
height,
|
||||
left: 0,
|
||||
right: width,
|
||||
top: 0,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('showcase masonry layout', () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it('assigns every group to columns in stable round-robin order', () => {
|
||||
const layout = createSequentialShowcaseMasonryLayout({
|
||||
containerWidth: 1296,
|
||||
gap: 16,
|
||||
itemHeights: [600, 300, 450, 200, 400, 250],
|
||||
});
|
||||
|
||||
expect(layout.columnCount).toBe(3);
|
||||
expect(layout.items.map((item) => item.columnIndex)).toEqual([
|
||||
0, 1, 2, 0, 1, 2,
|
||||
]);
|
||||
expect(layout.items.map((item) => item.top)).toEqual([
|
||||
0, 0, 0, 616, 316, 466,
|
||||
]);
|
||||
expect(layout.columnHeights).toEqual([816, 716, 716]);
|
||||
expect(layout.containerHeight).toBe(816);
|
||||
});
|
||||
|
||||
it('derives three, two, or one column from the actual container width', () => {
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({
|
||||
containerWidth: 590.71,
|
||||
gap: 14.72,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({
|
||||
containerWidth: 590.72,
|
||||
gap: 14.72,
|
||||
}),
|
||||
).toBe(2);
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({
|
||||
containerWidth: 893.43,
|
||||
gap: 14.72,
|
||||
}),
|
||||
).toBe(2);
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({
|
||||
containerWidth: 893.44,
|
||||
gap: 14.72,
|
||||
}),
|
||||
).toBe(3);
|
||||
expect(
|
||||
resolveShowcaseMasonryColumnCount({ containerWidth: 1600, gap: 14.72 }),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('applies and resets absolute card positions without changing DOM order', () => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'creation-landing__asset-waterfall';
|
||||
container.style.columnGap = '16px';
|
||||
container.getBoundingClientRect = () => createRect(900, 0);
|
||||
const heights = [600, 300, 450, 200, 400, 250];
|
||||
const cards = heights.map((height, index) => {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'creation-landing__asset-card';
|
||||
card.textContent = `card-${index + 1}`;
|
||||
card.getBoundingClientRect = () =>
|
||||
createRect(Number.parseFloat(card.style.width) || 0, height);
|
||||
container.append(card);
|
||||
return card;
|
||||
});
|
||||
document.body.append(container);
|
||||
|
||||
const layout = applySequentialShowcaseMasonry(container);
|
||||
|
||||
expect(layout?.columnCount).toBe(3);
|
||||
expect(container.dataset.masonryColumns).toBe('3');
|
||||
expect(cards.map((card) => card.dataset.masonryColumn)).toEqual([
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
]);
|
||||
expect(cards.map((card) => card.style.top)).toEqual([
|
||||
'0px',
|
||||
'0px',
|
||||
'0px',
|
||||
'616px',
|
||||
'316px',
|
||||
'466px',
|
||||
]);
|
||||
expect(
|
||||
Array.from(container.children).map((card) => card.textContent),
|
||||
).toEqual(['card-1', 'card-2', 'card-3', 'card-4', 'card-5', 'card-6']);
|
||||
|
||||
resetSequentialShowcaseMasonry(container);
|
||||
|
||||
expect(container.dataset.masonryColumns).toBeUndefined();
|
||||
expect(container.style.height).toBe('');
|
||||
expect(cards.every((card) => card.style.position === '')).toBe(true);
|
||||
expect(cards.every((card) => card.style.top === '')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the flow fallback until every expected card has a valid height', () => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'creation-landing__asset-waterfall';
|
||||
container.getBoundingClientRect = () => createRect(900, 0);
|
||||
const card = document.createElement('article');
|
||||
card.className = 'creation-landing__asset-card';
|
||||
card.getBoundingClientRect = () => createRect(288, 0);
|
||||
container.append(card);
|
||||
document.body.append(container);
|
||||
|
||||
expect(
|
||||
applySequentialShowcaseMasonry(container, { expectedItemCount: 2 }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.classList.contains(
|
||||
'creation-landing__asset-waterfall--masonry',
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
applySequentialShowcaseMasonry(container, { expectedItemCount: 1 }),
|
||||
).toBeNull();
|
||||
expect(container.style.height).toBe('');
|
||||
expect(card.style.position).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
export const SHOWCASE_MASONRY_MAX_COLUMNS = 3;
|
||||
export const SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX = 288;
|
||||
export const SHOWCASE_MASONRY_FALLBACK_GAP_PX = 14.72;
|
||||
|
||||
export type ShowcaseMasonryItemLayout = {
|
||||
columnIndex: number;
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export type ShowcaseMasonryLayout = {
|
||||
columnCount: number;
|
||||
columnHeights: number[];
|
||||
columnWidth: number;
|
||||
containerHeight: number;
|
||||
items: ShowcaseMasonryItemLayout[];
|
||||
};
|
||||
|
||||
type CreateShowcaseMasonryLayoutOptions = {
|
||||
containerWidth: number;
|
||||
gap: number;
|
||||
itemHeights: number[];
|
||||
maxColumns?: number;
|
||||
minColumnWidth?: number;
|
||||
};
|
||||
|
||||
function normalizeFiniteNumber(value: number, fallback = 0) {
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function resolveShowcaseMasonryColumnCount({
|
||||
containerWidth,
|
||||
gap,
|
||||
maxColumns = SHOWCASE_MASONRY_MAX_COLUMNS,
|
||||
minColumnWidth = SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX,
|
||||
}: Omit<CreateShowcaseMasonryLayoutOptions, 'itemHeights'>) {
|
||||
const safeContainerWidth = Math.max(0, normalizeFiniteNumber(containerWidth));
|
||||
const safeGap = Math.max(0, normalizeFiniteNumber(gap));
|
||||
const safeMaxColumns = Math.max(
|
||||
1,
|
||||
Math.floor(normalizeFiniteNumber(maxColumns, 1)),
|
||||
);
|
||||
const safeMinColumnWidth = Math.max(
|
||||
1,
|
||||
normalizeFiniteNumber(minColumnWidth, 1),
|
||||
);
|
||||
const fittingColumns = Math.max(
|
||||
1,
|
||||
Math.floor((safeContainerWidth + safeGap) / (safeMinColumnWidth + safeGap)),
|
||||
);
|
||||
return Math.min(safeMaxColumns, fittingColumns);
|
||||
}
|
||||
|
||||
export function createSequentialShowcaseMasonryLayout({
|
||||
containerWidth,
|
||||
gap,
|
||||
itemHeights,
|
||||
maxColumns = SHOWCASE_MASONRY_MAX_COLUMNS,
|
||||
minColumnWidth = SHOWCASE_MASONRY_MIN_COLUMN_WIDTH_PX,
|
||||
}: CreateShowcaseMasonryLayoutOptions): ShowcaseMasonryLayout {
|
||||
const safeContainerWidth = Math.max(0, normalizeFiniteNumber(containerWidth));
|
||||
const safeGap = Math.max(0, normalizeFiniteNumber(gap));
|
||||
const columnCount = resolveShowcaseMasonryColumnCount({
|
||||
containerWidth: safeContainerWidth,
|
||||
gap: safeGap,
|
||||
maxColumns,
|
||||
minColumnWidth,
|
||||
});
|
||||
const columnWidth = Math.max(
|
||||
0,
|
||||
(safeContainerWidth - safeGap * (columnCount - 1)) / columnCount,
|
||||
);
|
||||
const occupiedColumnHeights = Array.from<number>({
|
||||
length: columnCount,
|
||||
}).fill(0);
|
||||
const items = itemHeights.map((rawHeight, index) => {
|
||||
const height = Math.max(0, normalizeFiniteNumber(rawHeight));
|
||||
const columnIndex = index % columnCount;
|
||||
const top = occupiedColumnHeights[columnIndex] ?? 0;
|
||||
const item = {
|
||||
columnIndex,
|
||||
height,
|
||||
left: columnIndex * (columnWidth + safeGap),
|
||||
top,
|
||||
width: columnWidth,
|
||||
};
|
||||
occupiedColumnHeights[columnIndex] = top + height + safeGap;
|
||||
return item;
|
||||
});
|
||||
const columnHeights = occupiedColumnHeights.map((height) =>
|
||||
height > 0 ? height - safeGap : 0,
|
||||
);
|
||||
|
||||
return {
|
||||
columnCount,
|
||||
columnHeights,
|
||||
columnWidth,
|
||||
containerHeight: Math.max(0, ...columnHeights),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContainerGap(container: HTMLElement) {
|
||||
const view = container.ownerDocument.defaultView;
|
||||
const rawGap = view?.getComputedStyle(container).columnGap ?? '';
|
||||
const gap = Number.parseFloat(rawGap);
|
||||
return Number.isFinite(gap) ? gap : SHOWCASE_MASONRY_FALLBACK_GAP_PX;
|
||||
}
|
||||
|
||||
function getMasonryCards(container: HTMLElement) {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
':scope > .creation-landing__asset-card',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resetSequentialShowcaseMasonry(container: HTMLElement) {
|
||||
container.classList.remove('creation-landing__asset-waterfall--masonry');
|
||||
container.style.removeProperty('height');
|
||||
delete container.dataset.masonryColumns;
|
||||
for (const card of getMasonryCards(container)) {
|
||||
card.style.removeProperty('position');
|
||||
card.style.removeProperty('width');
|
||||
card.style.removeProperty('left');
|
||||
card.style.removeProperty('top');
|
||||
delete card.dataset.masonryColumn;
|
||||
}
|
||||
}
|
||||
|
||||
export function applySequentialShowcaseMasonry(
|
||||
container: HTMLElement,
|
||||
{ expectedItemCount }: { expectedItemCount?: number } = {},
|
||||
) {
|
||||
const containerWidth = container.getBoundingClientRect().width;
|
||||
const cards = getMasonryCards(container);
|
||||
if (
|
||||
!Number.isFinite(containerWidth) ||
|
||||
containerWidth <= 0 ||
|
||||
!cards.length ||
|
||||
(expectedItemCount !== undefined && cards.length !== expectedItemCount)
|
||||
) {
|
||||
resetSequentialShowcaseMasonry(container);
|
||||
return null;
|
||||
}
|
||||
|
||||
const gap = resolveContainerGap(container);
|
||||
const columnCount = resolveShowcaseMasonryColumnCount({
|
||||
containerWidth,
|
||||
gap,
|
||||
});
|
||||
const columnWidth = Math.max(
|
||||
0,
|
||||
(containerWidth - gap * (columnCount - 1)) / columnCount,
|
||||
);
|
||||
|
||||
container.classList.add('creation-landing__asset-waterfall--masonry');
|
||||
cards.forEach((card, index) => {
|
||||
const columnIndex = index % columnCount;
|
||||
card.style.position = 'absolute';
|
||||
card.style.width = `${columnWidth}px`;
|
||||
card.style.left = `${columnIndex * (columnWidth + gap)}px`;
|
||||
card.style.top = '0px';
|
||||
});
|
||||
|
||||
const itemHeights = cards.map((card) => card.getBoundingClientRect().height);
|
||||
if (itemHeights.some((height) => !Number.isFinite(height) || height <= 0)) {
|
||||
resetSequentialShowcaseMasonry(container);
|
||||
return null;
|
||||
}
|
||||
const layout = createSequentialShowcaseMasonryLayout({
|
||||
containerWidth,
|
||||
gap,
|
||||
itemHeights,
|
||||
});
|
||||
cards.forEach((card, index) => {
|
||||
const item = layout.items[index];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
card.style.left = `${item.left}px`;
|
||||
card.style.top = `${item.top}px`;
|
||||
card.dataset.masonryColumn = String(item.columnIndex + 1);
|
||||
});
|
||||
container.style.height = `${layout.containerHeight}px`;
|
||||
container.dataset.masonryColumns = String(layout.columnCount);
|
||||
return layout;
|
||||
}
|
||||
@@ -3,16 +3,33 @@ import { Image as ImageIcon, X } from 'lucide-react';
|
||||
import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts';
|
||||
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
|
||||
import type { RightClickMenuHandler } from './common.ts';
|
||||
|
||||
function AttachmentChip({
|
||||
attachment,
|
||||
onRemove,
|
||||
onRightClickMenu,
|
||||
}: {
|
||||
attachment: EditorAgentAttachmentRef;
|
||||
onRemove?: () => void;
|
||||
onRightClickMenu?: RightClickMenuHandler;
|
||||
}) {
|
||||
const label = attachment.label?.trim() || attachment.referenceId;
|
||||
return (
|
||||
<span className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm">
|
||||
<span
|
||||
className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm"
|
||||
onContextMenu={
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
...attachment,
|
||||
kind: 'attachment',
|
||||
mediaType: 'image',
|
||||
suggestedFileName: label,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
{onRemove ? (
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
|
||||
import { PlatformToolModalShell } from '@/src/components/common/PlatformToolModalShell.tsx';
|
||||
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
|
||||
import type {
|
||||
AttachmentPickerTab,
|
||||
EditorAgentAttachmentOption,
|
||||
} from './useConversationAttachments.ts';
|
||||
|
||||
export function AttachmentPicker({
|
||||
open,
|
||||
tab,
|
||||
canvasOptions,
|
||||
libraryOptions,
|
||||
selectedKeys,
|
||||
attachmentError,
|
||||
onTabChange,
|
||||
onToggleKey,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
tab: AttachmentPickerTab;
|
||||
canvasOptions: EditorAgentAttachmentOption[];
|
||||
libraryOptions: EditorAgentAttachmentOption[];
|
||||
selectedKeys: Set<string>;
|
||||
attachmentError: string | null;
|
||||
onTabChange: (tab: AttachmentPickerTab) => void;
|
||||
onToggleKey: (key: string) => void;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions;
|
||||
|
||||
return (
|
||||
<PlatformToolModalShell
|
||||
open={open}
|
||||
title="选择图片附件"
|
||||
size="md"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<PlatformActionButton tone="ghost" size="sm" onClick={onClose}>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton tone="primary" size="sm" onClick={onApply}>
|
||||
应用
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-[18rem] flex-col gap-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
tab === 'canvas'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
onClick={() => onTabChange('canvas')}
|
||||
>
|
||||
画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
tab === 'library'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
onClick={() => onTabChange('library')}
|
||||
>
|
||||
素材库
|
||||
</button>
|
||||
</div>
|
||||
{attachmentError ? (
|
||||
<div className="rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
{attachmentError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
|
||||
{visibleOptions.map((option) => {
|
||||
const label =
|
||||
option.attachment.label?.trim() || option.attachment.referenceId;
|
||||
return (
|
||||
<label
|
||||
key={option.key}
|
||||
className="flex cursor-pointer flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-2 text-sm text-slate-700 shadow-sm"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
option.attachment.thumbnailSrc ?? option.attachment.imageSrc
|
||||
}
|
||||
objectKey={option.attachment.objectKey}
|
||||
refreshKey={option.attachment.referenceId}
|
||||
alt=""
|
||||
className="aspect-square rounded-xl bg-slate-100 object-cover"
|
||||
/>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择${option.sourceLabel}图片 ${label}`}
|
||||
checked={selectedKeys.has(option.key)}
|
||||
onChange={() => onToggleKey(option.key)}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformToolModalShell>
|
||||
);
|
||||
}
|
||||
+887
-14
File diff suppressed because it is too large
Load Diff
+31
-375
@@ -9,22 +9,15 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type FormEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
EDITOR_AGENT_MAX_ATTACHMENTS,
|
||||
type EditorAgentAttachmentRef,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
|
||||
import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx';
|
||||
import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx';
|
||||
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
|
||||
import { AttachmentPicker } from '@/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx';
|
||||
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
||||
import {
|
||||
MessageBubble,
|
||||
@@ -34,25 +27,14 @@ import type {
|
||||
CanvasLayer,
|
||||
EditorAsset,
|
||||
} from '@/src/components/image-editor/ImageCanvasEditorTypes.ts';
|
||||
import { probeImageFileDimensions } from '@/src/components/image-editor/ImageCanvasFileModel.ts';
|
||||
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
import { uploadEditorMediaAssetFile } from '@/src/services/image-editor/editorMediaAssetUploadClient.ts';
|
||||
import { createEditorProjectResource } from '@/src/services/image-editor/editorProjectClient.ts';
|
||||
|
||||
import { useImageCanvasContextStore } from '../useImageCanvasContextStore.ts';
|
||||
import { useConversationAttachments } from './useConversationAttachments.ts';
|
||||
import {
|
||||
type EditorAgentConversationClient,
|
||||
useEditorAgentConversation,
|
||||
} from './useEditorAgentConversation';
|
||||
|
||||
type AttachmentPickerTab = 'canvas' | 'library';
|
||||
|
||||
type EditorAgentAttachmentOption = {
|
||||
key: string;
|
||||
sourceLabel: string;
|
||||
attachment: EditorAgentAttachmentRef;
|
||||
};
|
||||
|
||||
type EditorAgentConversationPanelViewProps = {
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
@@ -68,170 +50,6 @@ function stopAgentPanelWheel(event: ReactWheelEvent<HTMLElement>) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function isImageLayer(layer: CanvasLayer) {
|
||||
return (
|
||||
(layer.mediaType ?? 'image') === 'image' &&
|
||||
Boolean(layer.resourceId?.trim()) &&
|
||||
layer.src.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function isImageAsset(asset: EditorAsset) {
|
||||
return (asset.mediaType ?? 'image') === 'image' && asset.src.trim();
|
||||
}
|
||||
|
||||
function createCanvasAttachmentOptions(
|
||||
layers: CanvasLayer[] = [],
|
||||
): EditorAgentAttachmentOption[] {
|
||||
return layers.filter(isImageLayer).map((layer) => {
|
||||
const attachment: EditorAgentAttachmentRef = {
|
||||
source: 'canvas_resource',
|
||||
referenceId: layer.resourceId || layer.id,
|
||||
objectKey: layer.objectKey ?? null,
|
||||
imageSrc: layer.src,
|
||||
thumbnailSrc: layer.thumbnailSrc ?? null,
|
||||
label: layer.title,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
};
|
||||
return {
|
||||
key: attachmentKey(attachment),
|
||||
sourceLabel: '画布',
|
||||
attachment,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createLibraryAttachmentOptions(
|
||||
assets: EditorAsset[] = [],
|
||||
): EditorAgentAttachmentOption[] {
|
||||
return assets.filter(isImageAsset).map((asset) => {
|
||||
const attachment: EditorAgentAttachmentRef = {
|
||||
source: 'library_asset',
|
||||
referenceId: asset.id,
|
||||
objectKey: asset.objectKey ?? null,
|
||||
imageSrc: asset.src,
|
||||
thumbnailSrc: asset.thumbnailSrc ?? null,
|
||||
label: asset.label,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
return {
|
||||
key: attachmentKey(attachment),
|
||||
sourceLabel: '素材库',
|
||||
attachment,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function AttachmentPickerModal({
|
||||
open,
|
||||
tab,
|
||||
canvasOptions,
|
||||
libraryOptions,
|
||||
selectedKeys,
|
||||
attachmentError,
|
||||
onTabChange,
|
||||
onToggleKey,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
tab: AttachmentPickerTab;
|
||||
canvasOptions: EditorAgentAttachmentOption[];
|
||||
libraryOptions: EditorAgentAttachmentOption[];
|
||||
selectedKeys: Set<string>;
|
||||
attachmentError: string | null;
|
||||
onTabChange: (tab: AttachmentPickerTab) => void;
|
||||
onToggleKey: (key: string) => void;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions;
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title="选择图片附件"
|
||||
size="md"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<PlatformActionButton tone="ghost" size="sm" onClick={onClose}>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton tone="primary" size="sm" onClick={onApply}>
|
||||
应用
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-[18rem] flex-col gap-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
tab === 'canvas'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
onClick={() => onTabChange('canvas')}
|
||||
>
|
||||
画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
tab === 'library'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
onClick={() => onTabChange('library')}
|
||||
>
|
||||
素材库
|
||||
</button>
|
||||
</div>
|
||||
{attachmentError ? (
|
||||
<div className="rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
{attachmentError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
|
||||
{visibleOptions.map((option) => {
|
||||
const label =
|
||||
option.attachment.label?.trim() || option.attachment.referenceId;
|
||||
return (
|
||||
<label
|
||||
key={option.key}
|
||||
className="flex cursor-pointer flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-2 text-sm text-slate-700 shadow-sm"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
option.attachment.thumbnailSrc ?? option.attachment.imageSrc
|
||||
}
|
||||
objectKey={option.attachment.objectKey}
|
||||
refreshKey={option.attachment.referenceId}
|
||||
alt=""
|
||||
className="aspect-square rounded-xl bg-slate-100 object-cover"
|
||||
/>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择${option.sourceLabel}图片 ${label}`}
|
||||
checked={selectedKeys.has(option.key)}
|
||||
onChange={() => onToggleKey(option.key)}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorAgentConversationPanelView({
|
||||
open,
|
||||
onToggleOpen,
|
||||
@@ -258,6 +76,7 @@ export function EditorAgentConversationPanelView({
|
||||
isCreatingConversation,
|
||||
isDeletingConversation,
|
||||
isWaiting,
|
||||
isPatienceNoticeVisible,
|
||||
toolCallAction,
|
||||
isToolCallActionPending,
|
||||
errorMessage,
|
||||
@@ -275,70 +94,31 @@ export function EditorAgentConversationPanelView({
|
||||
onConfirmSent,
|
||||
});
|
||||
const [draftText, setDraftText] = useState('');
|
||||
const [attachments, setAttachments] = useState<EditorAgentAttachmentRef[]>(
|
||||
[],
|
||||
);
|
||||
const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false);
|
||||
const [attachmentPickerTab, setAttachmentPickerTab] =
|
||||
useState<AttachmentPickerTab>('canvas');
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [isPastingAttachment, setIsPastingAttachment] = useState(false);
|
||||
const [draftAttachmentKeys, setDraftAttachmentKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const {
|
||||
attachments,
|
||||
attachmentError,
|
||||
isPastingAttachment,
|
||||
attachmentPickerOpen,
|
||||
attachmentPickerTab,
|
||||
draftAttachmentKeys,
|
||||
canvasAttachmentOptions,
|
||||
libraryAttachmentOptions,
|
||||
setAttachmentPickerTab,
|
||||
openAttachmentPicker,
|
||||
closeAttachmentPicker,
|
||||
toggleAttachmentKey,
|
||||
applyAttachmentSelection,
|
||||
referenceContextAsset,
|
||||
handleInputPaste,
|
||||
removeAttachment,
|
||||
consumeAttachments,
|
||||
restoreAttachments,
|
||||
} = useConversationAttachments({ projectId, layers, assets });
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
const canvasAttachmentOptions = useMemo(
|
||||
() => createCanvasAttachmentOptions(layers),
|
||||
[layers],
|
||||
);
|
||||
const libraryAttachmentOptions = useMemo(
|
||||
() => createLibraryAttachmentOptions(assets),
|
||||
[assets],
|
||||
);
|
||||
const attachmentOptionsByKey = useMemo(() => {
|
||||
const optionMap = new Map<string, EditorAgentAttachmentOption>();
|
||||
[...canvasAttachmentOptions, ...libraryAttachmentOptions].forEach(
|
||||
(option) => optionMap.set(option.key, option),
|
||||
);
|
||||
return optionMap;
|
||||
}, [canvasAttachmentOptions, libraryAttachmentOptions]);
|
||||
|
||||
const hasProject = Boolean(projectId?.trim());
|
||||
const isConversationBusy = isWaiting || isToolCallActionPending;
|
||||
|
||||
const openAttachmentPicker = () => {
|
||||
setAttachmentError(null);
|
||||
setDraftAttachmentKeys(new Set(attachments.map(attachmentKey)));
|
||||
setAttachmentPickerOpen(true);
|
||||
};
|
||||
|
||||
const toggleAttachmentKey = (key: string) => {
|
||||
setDraftAttachmentKeys((currentKeys) => {
|
||||
const nextKeys = new Set(currentKeys);
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key);
|
||||
} else {
|
||||
nextKeys.add(key);
|
||||
}
|
||||
return nextKeys;
|
||||
});
|
||||
};
|
||||
|
||||
const applyAttachmentSelection = () => {
|
||||
const nextAttachments = Array.from(draftAttachmentKeys)
|
||||
.map((key) => attachmentOptionsByKey.get(key)?.attachment)
|
||||
.filter((attachment): attachment is EditorAgentAttachmentRef =>
|
||||
Boolean(attachment),
|
||||
);
|
||||
if (nextAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
return;
|
||||
}
|
||||
setAttachments(nextAttachments);
|
||||
setAttachmentPickerOpen(false);
|
||||
};
|
||||
|
||||
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (isWaiting) {
|
||||
@@ -352,139 +132,12 @@ export function EditorAgentConversationPanelView({
|
||||
return;
|
||||
}
|
||||
setDraftText('');
|
||||
const nextAttachments = attachments;
|
||||
setAttachments([]);
|
||||
const nextAttachments = consumeAttachments();
|
||||
void sendMessage(text, nextAttachments).catch(() => {
|
||||
setDraftText((currentText) => (currentText ? currentText : text));
|
||||
setAttachments((currentAttachments) =>
|
||||
currentAttachments.length ? currentAttachments : nextAttachments,
|
||||
);
|
||||
restoreAttachments(nextAttachments);
|
||||
});
|
||||
};
|
||||
const appendAttachments = (nextAttachments: EditorAgentAttachmentRef[]) => {
|
||||
function mergeAttachments(
|
||||
currentAttachments: EditorAgentAttachmentRef[],
|
||||
nextAttachments: EditorAgentAttachmentRef[],
|
||||
) {
|
||||
const merged = [...currentAttachments];
|
||||
const existingKeys = new Set(currentAttachments.map(attachmentKey));
|
||||
nextAttachments.forEach((attachment) => {
|
||||
const key = attachmentKey(attachment);
|
||||
if (!existingKeys.has(key)) {
|
||||
existingKeys.add(key);
|
||||
merged.push(attachment);
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
const mergedAttachments = mergeAttachments(attachments, nextAttachments);
|
||||
if (mergedAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
return false;
|
||||
}
|
||||
setAttachments(mergedAttachments);
|
||||
setAttachmentError(null);
|
||||
return true;
|
||||
};
|
||||
|
||||
const createPastedAgentImageAttachment = async (
|
||||
file: File,
|
||||
): Promise<EditorAgentAttachmentRef> => {
|
||||
if (!projectId?.trim()) {
|
||||
throw new Error('缺少画布项目');
|
||||
}
|
||||
const [upload, dimensions] = await Promise.all([
|
||||
uploadEditorMediaAssetFile(file, 'image', {
|
||||
pathSegments: ['editor', 'agent-paste', 'image', `${Date.now()}`],
|
||||
entityId: projectId,
|
||||
metadata: {
|
||||
source: 'agent-input-paste',
|
||||
},
|
||||
}),
|
||||
probeImageFileDimensions(file),
|
||||
]);
|
||||
const width = dimensions?.width ?? 1;
|
||||
const height = dimensions?.height ?? 1;
|
||||
const resource = await createEditorProjectResource(projectId, {
|
||||
imageSrc: upload.src,
|
||||
objectKey: upload.objectKey,
|
||||
assetObjectId: upload.assetObjectId,
|
||||
width,
|
||||
height,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
return {
|
||||
source: 'canvas_resource',
|
||||
referenceId: resource.resourceId,
|
||||
objectKey: resource.objectKey ?? upload.objectKey,
|
||||
imageSrc: resource.imageSrc,
|
||||
thumbnailSrc: null,
|
||||
label: resource.label ?? '粘贴图片',
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
};
|
||||
};
|
||||
function extractClipboardImageFiles(
|
||||
clipboardData: DataTransfer | null,
|
||||
): File[] {
|
||||
if (!clipboardData) {
|
||||
return [];
|
||||
}
|
||||
const fileItems = Array.from(clipboardData.files ?? []).filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
return [...fileItems];
|
||||
}
|
||||
|
||||
const handleInputPaste = (
|
||||
event: ReactClipboardEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
const imageFiles = extractClipboardImageFiles(event.clipboardData);
|
||||
if (!imageFiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPastingAttachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (attachments.length >= EDITOR_AGENT_MAX_ATTACHMENTS) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingAttachmentSlots =
|
||||
EDITOR_AGENT_MAX_ATTACHMENTS - attachments.length;
|
||||
const uploadFiles = imageFiles.slice(0, remainingAttachmentSlots);
|
||||
const hasOverflow = uploadFiles.length < imageFiles.length;
|
||||
// TODO: deduplicate those existing assets
|
||||
setIsPastingAttachment(true);
|
||||
setAttachmentError('图片上传中');
|
||||
void Promise.all(
|
||||
uploadFiles.map((file) => createPastedAgentImageAttachment(file)),
|
||||
)
|
||||
.then((pastedAttachments) => {
|
||||
if (appendAttachments(pastedAttachments) && hasOverflow) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAttachmentError('图片粘贴失败,请重试');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPastingAttachment(false);
|
||||
});
|
||||
};
|
||||
|
||||
const removeAttachment = (targetAttachment: EditorAgentAttachmentRef) => {
|
||||
const key = attachmentKey(targetAttachment);
|
||||
setAttachments((currentAttachments) =>
|
||||
currentAttachments.filter(
|
||||
(attachment) => attachmentKey(attachment) !== key,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
@@ -608,13 +261,16 @@ export function EditorAgentConversationPanelView({
|
||||
}
|
||||
onConfirmToolCall={confirmToolCall}
|
||||
onCancelToolCall={cancelToolCall}
|
||||
onReferenceImage={referenceContextAsset}
|
||||
onJobCompleted={() => {
|
||||
void refreshActiveConversation();
|
||||
onCanvasRefreshRequested?.();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{isWaiting ? <ThinkingBubble /> : null}
|
||||
{isWaiting ? (
|
||||
<ThinkingBubble showPatienceNotice={isPatienceNoticeVisible} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-3xl border border-dashed border-slate-200 bg-white/70 px-4 py-8 text-center text-sm text-slate-400">
|
||||
@@ -686,7 +342,7 @@ export function EditorAgentConversationPanelView({
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
<AttachmentPickerModal
|
||||
<AttachmentPicker
|
||||
open={attachmentPickerOpen}
|
||||
tab={attachmentPickerTab}
|
||||
canvasOptions={canvasAttachmentOptions}
|
||||
@@ -696,7 +352,7 @@ export function EditorAgentConversationPanelView({
|
||||
onTabChange={setAttachmentPickerTab}
|
||||
onToggleKey={toggleAttachmentKey}
|
||||
onApply={applyAttachmentSelection}
|
||||
onClose={() => setAttachmentPickerOpen(false)}
|
||||
onClose={closeAttachmentPicker}
|
||||
/>
|
||||
<PlatformDangerConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,16 @@ import {
|
||||
type EditorAgentMessage,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
|
||||
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
||||
import {
|
||||
attachmentKey,
|
||||
type EditorAgentContextAsset,
|
||||
} from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
||||
import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx';
|
||||
import ToolCallView from '@/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx';
|
||||
|
||||
import { MessageBubbleRightClickMenu } from './MessageBubbleRightClickMenu.tsx';
|
||||
import { useRightClickMenu } from './useRightClickMenu.ts';
|
||||
|
||||
function messageRoleLabel(role: EditorAgentMessage['role']) {
|
||||
if (role === 'user') {
|
||||
return '你';
|
||||
@@ -14,9 +20,16 @@ function messageRoleLabel(role: EditorAgentMessage['role']) {
|
||||
return 'Agent';
|
||||
}
|
||||
|
||||
export function ThinkingBubble() {
|
||||
export function ThinkingBubble({
|
||||
showPatienceNotice = false,
|
||||
}: {
|
||||
showPatienceNotice?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<article className="flex justify-start" aria-label="Agent思考中">
|
||||
<article
|
||||
className="flex justify-start"
|
||||
aria-label={showPatienceNotice ? 'Agent仍在处理中' : 'Agent思考中'}
|
||||
>
|
||||
<div className="max-w-[86%] rounded-3xl border border-slate-200 bg-white px-3.5 py-3 text-sm leading-6 shadow-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="flex gap-0.5">
|
||||
@@ -33,6 +46,9 @@ export function ThinkingBubble() {
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</span>
|
||||
{showPatienceNotice ? (
|
||||
<span className="text-slate-600">仍在处理中,请耐心等待</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -45,6 +61,8 @@ type MessageBubbleProps = {
|
||||
onConfirmToolCall: (messageId: number) => Promise<void>;
|
||||
onCancelToolCall: (messageId: number) => Promise<void>;
|
||||
onJobCompleted?: () => void;
|
||||
// TODO prop drilling
|
||||
onReferenceImage?: (asset: EditorAgentContextAsset) => boolean;
|
||||
};
|
||||
|
||||
export function MessageBubble({
|
||||
@@ -53,7 +71,14 @@ export function MessageBubble({
|
||||
onConfirmToolCall,
|
||||
onCancelToolCall,
|
||||
onJobCompleted,
|
||||
onReferenceImage,
|
||||
}: MessageBubbleProps) {
|
||||
const {
|
||||
rightClickMenu,
|
||||
openRightClickMenu,
|
||||
closeRightClickMenu,
|
||||
runRightClickAction,
|
||||
} = useRightClickMenu({ onReferenceImage });
|
||||
const systemErrorText =
|
||||
message.role === 'system' &&
|
||||
!message.toolCall &&
|
||||
@@ -61,7 +86,16 @@ export function MessageBubble({
|
||||
? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length)
|
||||
: null;
|
||||
|
||||
if (message.role === 'system' && !message.toolCall && systemErrorText === null) {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
const isSystemError = systemErrorText !== null;
|
||||
const visibleText = systemErrorText ?? (!isSystem ? message.text : '');
|
||||
|
||||
if (
|
||||
message.role === 'system' &&
|
||||
!message.toolCall &&
|
||||
systemErrorText === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
@@ -81,56 +115,78 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
const isSystemError = systemErrorText !== null;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
|
||||
aria-label={
|
||||
isSystemError
|
||||
? 'Agent错误'
|
||||
: isSystem
|
||||
? 'Agent操作'
|
||||
: `${messageRoleLabel(message.role)}消息`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isSystem && !isSystemError
|
||||
? 'max-w-[86%]'
|
||||
: `max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
|
||||
isUser
|
||||
? 'bg-slate-900 text-white'
|
||||
: isSystemError || message.toolCall?.status === 'failed'
|
||||
? 'border border-red-200 bg-red-50 text-red-700'
|
||||
: 'border border-slate-200 bg-white text-slate-700'
|
||||
}`
|
||||
<>
|
||||
<article
|
||||
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
|
||||
aria-label={
|
||||
isSystemError
|
||||
? 'Agent错误'
|
||||
: isSystem
|
||||
? 'Agent操作'
|
||||
: `${messageRoleLabel(message.role)}消息`
|
||||
}
|
||||
onContextMenu={
|
||||
visibleText.trim()
|
||||
? (event) =>
|
||||
openRightClickMenu(event, { kind: 'text', text: visibleText })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(!isSystem || isSystemError) && (systemErrorText ?? message.text) ? (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{systemErrorText ?? message.text}
|
||||
</div>
|
||||
) : null}
|
||||
{!isSystem && message.attachments.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{message.attachments.map((attachment) => (
|
||||
<AttachmentChip
|
||||
key={attachmentKey(attachment)}
|
||||
attachment={attachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{message.toolCall ? (
|
||||
<ToolCallView
|
||||
toolCall={message.toolCall}
|
||||
onJobCompleted={onJobCompleted}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
<div
|
||||
className={
|
||||
isSystem && !isSystemError
|
||||
? 'max-w-[86%]'
|
||||
: `max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
|
||||
isUser
|
||||
? 'bg-slate-900 text-white'
|
||||
: isSystemError || message.toolCall?.status === 'failed'
|
||||
? 'border border-red-200 bg-red-50 text-red-700'
|
||||
: 'border border-slate-200 bg-white text-slate-700'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{(!isSystem || isSystemError) && (systemErrorText ?? message.text) ? (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{systemErrorText ?? message.text}
|
||||
</div>
|
||||
) : null}
|
||||
{!isSystem && message.attachments.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{message.attachments.map((attachment) => (
|
||||
<AttachmentChip
|
||||
key={attachmentKey(attachment)}
|
||||
attachment={attachment}
|
||||
onRightClickMenu={(event, asset) =>
|
||||
openRightClickMenu(event, { kind: 'asset', asset })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{message.toolCall ? (
|
||||
<ToolCallView
|
||||
toolCall={message.toolCall}
|
||||
onJobCompleted={onJobCompleted}
|
||||
onRightClickMenu={(event, asset) =>
|
||||
openRightClickMenu(event, { kind: 'asset', asset })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
{rightClickMenu ? (
|
||||
<MessageBubbleRightClickMenu
|
||||
x={rightClickMenu.x}
|
||||
y={rightClickMenu.y}
|
||||
target={rightClickMenu.target}
|
||||
pendingAction={rightClickMenu.pendingAction}
|
||||
resultAction={rightClickMenu.resultAction}
|
||||
result={rightClickMenu.result}
|
||||
onAction={(action) => void runRightClickAction(action)}
|
||||
onClose={closeRightClickMenu}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
contextAssetMediaSrc,
|
||||
type EditorAgentContextAsset,
|
||||
EditorAgentRightClickAction,
|
||||
type RightClickMenuTarget,
|
||||
} from './common.ts';
|
||||
|
||||
type MessageBubbleRightClickMenuProps = {
|
||||
x: number;
|
||||
y: number;
|
||||
target: RightClickMenuTarget;
|
||||
pendingAction: EditorAgentRightClickAction | null;
|
||||
resultAction: EditorAgentRightClickAction | null;
|
||||
result: 'success' | 'error' | null;
|
||||
onAction: (action: EditorAgentRightClickAction) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
|
||||
function actionLabel({
|
||||
action,
|
||||
idleLabel,
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
}: {
|
||||
action: EditorAgentRightClickAction;
|
||||
idleLabel: string;
|
||||
pendingAction: EditorAgentRightClickAction | null;
|
||||
resultAction: EditorAgentRightClickAction | null;
|
||||
result: 'success' | 'error' | null;
|
||||
}) {
|
||||
if (pendingAction === action) {
|
||||
return action === EditorAgentRightClickAction.DownloadAsset
|
||||
? '下载中'
|
||||
: action === EditorAgentRightClickAction.ReferenceImage
|
||||
? '引用中'
|
||||
: '复制中';
|
||||
}
|
||||
if (resultAction !== action) {
|
||||
return idleLabel;
|
||||
}
|
||||
if (result === 'success') {
|
||||
return action === EditorAgentRightClickAction.DownloadAsset
|
||||
? '已下载'
|
||||
: action === EditorAgentRightClickAction.ReferenceImage
|
||||
? '已引用'
|
||||
: '已复制';
|
||||
}
|
||||
if (result === 'error') {
|
||||
return action === EditorAgentRightClickAction.DownloadAsset
|
||||
? '下载失败'
|
||||
: action === EditorAgentRightClickAction.ReferenceImage
|
||||
? '引用失败'
|
||||
: '复制失败';
|
||||
}
|
||||
return idleLabel;
|
||||
}
|
||||
|
||||
function assetDownloadLabel(asset: EditorAgentContextAsset) {
|
||||
return `下载${
|
||||
asset.mediaType === 'image'
|
||||
? '图片'
|
||||
: asset.mediaType === 'video'
|
||||
? '视频'
|
||||
: '音频'
|
||||
}`;
|
||||
}
|
||||
|
||||
export function MessageBubbleRightClickMenu({
|
||||
x,
|
||||
y,
|
||||
target,
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
onAction,
|
||||
onClose,
|
||||
}: MessageBubbleRightClickMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const menu = menuRef.current;
|
||||
if (!menu || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const rect = menu.getBoundingClientRect();
|
||||
setPosition({
|
||||
x: Math.min(
|
||||
Math.max(x, VIEWPORT_MARGIN),
|
||||
Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
window.innerWidth - rect.width - VIEWPORT_MARGIN,
|
||||
),
|
||||
),
|
||||
y: Math.min(
|
||||
Math.max(y, VIEWPORT_MARGIN),
|
||||
Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
window.innerHeight - rect.height - VIEWPORT_MARGIN,
|
||||
),
|
||||
),
|
||||
});
|
||||
}, [target.kind, x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
if (menuRef.current?.contains(event.target as Node)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointerdown', handlePointerDown);
|
||||
window.addEventListener('contextmenu', handleContextMenu, true);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('scroll', onClose, true);
|
||||
window.addEventListener('resize', onClose);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handlePointerDown);
|
||||
window.removeEventListener('contextmenu', handleContextMenu, true);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('scroll', onClose, true);
|
||||
window.removeEventListener('resize', onClose);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="image-canvas-editor__context-menu"
|
||||
role="menu"
|
||||
aria-label={target.kind === 'text' ? '消息右键菜单' : '消息素材右键菜单'}
|
||||
style={{
|
||||
left: position?.x ?? x,
|
||||
top: position?.y ?? y,
|
||||
zIndex: 60,
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{target.kind === 'text' ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction(EditorAgentRightClickAction.CopyText)}
|
||||
>
|
||||
{actionLabel({
|
||||
action: EditorAgentRightClickAction.CopyText,
|
||||
idleLabel: '复制文本',
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
})}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{target.asset.mediaType === 'image' ? (
|
||||
<>
|
||||
{contextAssetMediaSrc(target.asset).trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() =>
|
||||
onAction(EditorAgentRightClickAction.ReferenceImage)
|
||||
}
|
||||
>
|
||||
{actionLabel({
|
||||
action: EditorAgentRightClickAction.ReferenceImage,
|
||||
idleLabel: '引用',
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
})}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction(EditorAgentRightClickAction.CopyImage)}
|
||||
>
|
||||
{actionLabel({
|
||||
action: EditorAgentRightClickAction.CopyImage,
|
||||
idleLabel: '复制图片',
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
})}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction(EditorAgentRightClickAction.DownloadAsset)}
|
||||
>
|
||||
{actionLabel({
|
||||
action: EditorAgentRightClickAction.DownloadAsset,
|
||||
idleLabel: assetDownloadLabel(target.asset),
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
})}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,16 @@ import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
import { ResolvedAssetVideo } from '@/src/components/ResolvedAssetVideo.tsx';
|
||||
import { getExternalGenerationJobStatus } from '@/src/services/external-generation';
|
||||
|
||||
import type { RightClickMenuHandler } from './common.ts';
|
||||
|
||||
function ToolCallView({
|
||||
toolCall,
|
||||
onJobCompleted,
|
||||
onRightClickMenu,
|
||||
}: {
|
||||
toolCall: EditorAgentToolCall;
|
||||
onJobCompleted?: () => void;
|
||||
onRightClickMenu?: RightClickMenuHandler;
|
||||
}) {
|
||||
const videos = toolCall.videos ?? [];
|
||||
const audios = toolCall.audios ?? [];
|
||||
@@ -69,6 +73,8 @@ function ToolCallView({
|
||||
disposed = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
// 轮询只允许新的 job/status source 重置;其余值通过当前 source 对应的闭包读取。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialDisplayError, initialDisplayStatus, jobId, shouldPoll]);
|
||||
const isCancelled = displayStatus === 'cancelled';
|
||||
const isCompleted = displayStatus === 'completed';
|
||||
@@ -107,6 +113,18 @@ function ToolCallView({
|
||||
<div
|
||||
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
|
||||
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
|
||||
onContextMenu={
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
kind: 'generated_media',
|
||||
mediaType: 'image',
|
||||
mediaSrc: image.imageSrc,
|
||||
objectKey: image.objectKey,
|
||||
suggestedFileName: `Agent生成图片-${index + 1}`,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={image.thumbnailSrc ?? image.imageSrc}
|
||||
@@ -125,6 +143,18 @@ function ToolCallView({
|
||||
<div
|
||||
key={`${toolCall.toolName}-${video.resourceId ?? video.objectKey ?? index}`}
|
||||
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
|
||||
onContextMenu={
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
kind: 'generated_media',
|
||||
mediaType: 'video',
|
||||
mediaSrc: video.videoSrc,
|
||||
objectKey: video.objectKey,
|
||||
suggestedFileName: `Agent生成视频-${index + 1}`,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ResolvedAssetVideo
|
||||
src={video.videoSrc}
|
||||
@@ -148,6 +178,18 @@ function ToolCallView({
|
||||
<div
|
||||
key={`${toolCall.toolName}-${audio.resourceId ?? audio.objectKey ?? index}`}
|
||||
className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 p-2"
|
||||
onContextMenu={
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
kind: 'generated_media',
|
||||
mediaType: 'audio',
|
||||
mediaSrc: audio.audioSrc,
|
||||
objectKey: audio.objectKey,
|
||||
suggestedFileName: `Agent生成音频-${index + 1}`,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Volume2
|
||||
className="h-4 w-4 shrink-0 text-slate-500"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user