收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
@@ -20,22 +20,24 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
refreshKey,
|
||||
'data-testid': dataTestId,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
refreshKey?: string | number | null;
|
||||
'data-testid'?: string;
|
||||
}) => (
|
||||
}) =>
|
||||
src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
data-refresh-key={refreshKey ?? undefined}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
) : null
|
||||
),
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
@@ -79,6 +81,24 @@ function stubReferenceImageUpload(dataUrl: string) {
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
}
|
||||
|
||||
test('renders missing draft notice with shared PlatformSubpanel chrome', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={{ ...createSession(), draft: null }}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const noticePanel = screen
|
||||
.getByText('还没有可编辑的拼图草稿')
|
||||
.closest('.platform-subpanel');
|
||||
|
||||
expect(noticePanel?.className).toContain('rounded-[1rem]');
|
||||
expect(noticePanel?.className).toContain('sm:p-5');
|
||||
expect(noticePanel?.className).toContain('text-[var(--platform-text-base)]');
|
||||
});
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
@@ -227,8 +247,27 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
const levelImage = screen.getByRole('img', { name: '雨夜猫街' });
|
||||
const mediaFrame = levelImage.closest('div.relative');
|
||||
expect(mediaFrame?.className).toContain('aspect-[4/3]');
|
||||
expect(mediaFrame?.className).not.toContain(
|
||||
'bg-[var(--platform-subpanel-fill)]',
|
||||
);
|
||||
expect(mediaFrame?.className).toContain('rounded-none');
|
||||
expect(screen.getByText('雨夜猫街')).toBeTruthy();
|
||||
const levelTitleButton = within(
|
||||
screen.getByLabelText('拼图关卡列表'),
|
||||
).getByRole('button', { name: '雨夜猫街' });
|
||||
const levelCard = levelTitleButton.closest('.platform-subpanel');
|
||||
expect(levelCard?.className).toContain('rounded-[1.35rem]');
|
||||
expect(levelCard?.className).toContain('p-0');
|
||||
expect(levelCard?.className).toContain('overflow-hidden');
|
||||
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const levelDialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(within(levelDialog).getByText('关卡名称').className).toContain(
|
||||
'tracking-[0.18em]',
|
||||
);
|
||||
});
|
||||
|
||||
test('result action bar restores draft trial entry', () => {
|
||||
@@ -334,10 +373,17 @@ describe('PuzzleResultView', () => {
|
||||
target: { value: '暖灯猫街合集' },
|
||||
});
|
||||
|
||||
expect(screen.getByText('保存中').className).toContain(
|
||||
'border-[var(--platform-warm-border)]',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(screen.getByText('已自动保存').className).toContain(
|
||||
'border-emerald-200',
|
||||
);
|
||||
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-session-1',
|
||||
expect.objectContaining({
|
||||
@@ -356,6 +402,38 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('auto save failure badge uses PlatformPillBadge danger chrome', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockRejectedValue(
|
||||
new Error('保存失败'),
|
||||
);
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
|
||||
fireEvent.change(screen.getByLabelText('作品名称'), {
|
||||
target: { value: '暖灯猫街异常版' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
const failureBadge = screen
|
||||
.getAllByText('保存失败')
|
||||
.find((element) => element.tagName.toLowerCase() === 'span');
|
||||
expect(failureBadge?.className).toContain(
|
||||
'border-[var(--platform-button-danger-border)]',
|
||||
);
|
||||
});
|
||||
|
||||
test('opens an independent level detail dialog for generation and test play', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
@@ -387,7 +465,9 @@ describe('PuzzleResultView', () => {
|
||||
name: '确认消耗泥点',
|
||||
});
|
||||
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
|
||||
fireEvent.click(
|
||||
within(confirmDialog).getByRole('button', { name: '确定' }),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_puzzle_images',
|
||||
@@ -405,7 +485,9 @@ describe('PuzzleResultView', () => {
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
expect(screen.getByRole('progressbar', { name: '画面生成进度' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: '画面生成进度' }),
|
||||
).toBeTruthy();
|
||||
const generatePayload = onExecuteAction.mock.calls[0]![0];
|
||||
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -421,6 +503,19 @@ describe('PuzzleResultView', () => {
|
||||
).toBeNull();
|
||||
const levelList = screen.getByLabelText('拼图关卡列表');
|
||||
expect(within(levelList).getAllByText('生成中').length).toBeGreaterThan(0);
|
||||
const generationBadges = within(levelList)
|
||||
.getAllByText('生成中')
|
||||
.map((element) => element.closest('span'));
|
||||
expect(
|
||||
generationBadges.some((badge) =>
|
||||
badge?.className.includes('bg-white/94'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
generationBadges.some((badge) =>
|
||||
badge?.className.includes('bg-amber-100'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const levelNameInput = within(dialog).getByLabelText('关卡名称');
|
||||
const formalImageTitle = within(dialog).getByText('画面图');
|
||||
@@ -439,7 +534,9 @@ describe('PuzzleResultView', () => {
|
||||
name: '关闭关卡图片预览',
|
||||
}),
|
||||
);
|
||||
expect(within(dialog).getByRole('button', { name: '更换参考图' })).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '更换参考图' }),
|
||||
).toBeTruthy();
|
||||
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
|
||||
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
|
||||
@@ -701,7 +798,9 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
|
||||
expect(
|
||||
within(publishDialog).getByText('还有关卡画面正在生成。'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
|
||||
).toHaveProperty('disabled', true);
|
||||
@@ -837,11 +936,30 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
expect(within(publishDialog).getByText('发布检查').className).toContain(
|
||||
'tracking-[0.18em]',
|
||||
);
|
||||
expect(within(publishDialog).getByText('封面关卡').className).toContain(
|
||||
'tracking-[0.18em]',
|
||||
);
|
||||
const coverImage = within(publishDialog).getByRole('img', {
|
||||
name: '雨夜猫街',
|
||||
});
|
||||
expect(coverImage.closest('div.relative')?.className).toContain(
|
||||
'aspect-square',
|
||||
);
|
||||
expect(coverImage.closest('div.relative')?.className).toContain(
|
||||
'border-[var(--platform-subpanel-border)]',
|
||||
);
|
||||
expect(coverImage.closest('div.relative')?.className).toContain(
|
||||
'bg-white/68',
|
||||
);
|
||||
expect(coverImage.getAttribute('data-refresh-key')).toBe(
|
||||
'2026-04-26T10:00:00.000Z:/puzzle/candidate-1.png:1',
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '发布拼图作品' })).getByRole(
|
||||
'button',
|
||||
{ name: /发布到广场/u },
|
||||
),
|
||||
within(publishDialog).getByRole('button', { name: /发布到广场/u }),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
@@ -1286,7 +1404,8 @@ describe('PuzzleResultView', () => {
|
||||
levels: [
|
||||
{
|
||||
...createSession().draft!.levels![0]!,
|
||||
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
pictureReference:
|
||||
'/generated-puzzle-assets/history/saved-reference.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1302,6 +1421,14 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const referenceImage = within(dialog).getByAltText('拼图参考图');
|
||||
const referenceRow = referenceImage.closest('div')?.parentElement;
|
||||
expect(referenceRow?.className).toContain('flex items-center gap-3');
|
||||
expect(referenceRow?.className).toContain('bg-white/72');
|
||||
expect(within(dialog).getByText('已选择参考图').className).toContain(
|
||||
'truncate',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
@@ -1313,7 +1440,8 @@ describe('PuzzleResultView', () => {
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
referenceImageSrc:
|
||||
'/generated-puzzle-assets/history/saved-reference.png',
|
||||
aiRedraw: true,
|
||||
}),
|
||||
);
|
||||
@@ -1367,7 +1495,8 @@ describe('PuzzleResultView', () => {
|
||||
levels: [
|
||||
{
|
||||
...createSession().draft!.levels![0]!,
|
||||
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
pictureReference:
|
||||
'/generated-puzzle-assets/history/saved-reference.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1387,18 +1516,22 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
expect(within(dialog).getByText('画面图')).toBeTruthy();
|
||||
expect(within(dialog).getByLabelText('上传参考图')).toBeTruthy();
|
||||
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toHaveProperty(
|
||||
'checked',
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
within(dialog).getByRole('switch', { name: 'AI重绘' }),
|
||||
).toHaveProperty('checked', true);
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '选择历史图片' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText('画面AI重绘要求(提示词)'), {
|
||||
target: { value: '只重绘第一关猫街画面' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.change(
|
||||
within(dialog).getByLabelText('画面AI重绘要求(提示词)'),
|
||||
{
|
||||
target: { value: '只重绘第一关猫街画面' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /重新生成画面/u }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
@@ -1429,7 +1562,8 @@ describe('PuzzleResultView', () => {
|
||||
levels: [
|
||||
{
|
||||
...createSession().draft!.levels![0]!,
|
||||
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
|
||||
pictureReference:
|
||||
'/generated-puzzle-assets/history/saved-reference.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1448,7 +1582,9 @@ describe('PuzzleResultView', () => {
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /重新生成画面/u }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
@@ -1497,14 +1633,18 @@ describe('PuzzleResultView', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('switch', { name: 'AI重绘' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
expect(within(dialog).getByAltText('拼图参考图')).toHaveProperty(
|
||||
'src',
|
||||
uploadedDataUrl,
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /重新生成画面/u }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
@@ -1539,11 +1679,10 @@ describe('PuzzleResultView', () => {
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const referenceInputs = within(dialog).getAllByLabelText('上传参考图', {
|
||||
const referenceInput = within(dialog).getByLabelText('上传描述参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(referenceInputs.length).toBeGreaterThanOrEqual(2);
|
||||
fireEvent.change(referenceInputs[referenceInputs.length - 1]!, {
|
||||
fireEvent.change(referenceInput, {
|
||||
target: {
|
||||
files: [new File(['x'], 'prompt-reference.png', { type: 'image/png' })],
|
||||
},
|
||||
@@ -1556,7 +1695,9 @@ describe('PuzzleResultView', () => {
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /重新生成画面/u }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
@@ -1628,7 +1769,19 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('智能修订拼图草稿'), {
|
||||
const creativeDraftInput = screen.getByLabelText('智能修订拼图草稿');
|
||||
const creativeDraftPanel = creativeDraftInput.closest('.platform-subpanel');
|
||||
const creativeDraftIconBadge = creativeDraftPanel?.querySelector(
|
||||
'span[aria-hidden="true"]',
|
||||
);
|
||||
|
||||
expect(creativeDraftPanel?.className).toContain('rounded-[1.35rem]');
|
||||
expect(creativeDraftPanel?.className).toContain('sm:p-4');
|
||||
expect(creativeDraftIconBadge?.className).toContain('h-9');
|
||||
expect(creativeDraftIconBadge?.className).toContain('rounded-full');
|
||||
expect(creativeDraftIconBadge?.className).toContain('bg-white/72');
|
||||
|
||||
fireEvent.change(creativeDraftInput, {
|
||||
target: { value: '把标题改得轻松一点' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改' }));
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
MessageSquareText,
|
||||
Play,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
@@ -24,13 +23,26 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformIconBadge } from '../common/PlatformIconBadge';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformProgressBar } from '../common/PlatformProgressBar';
|
||||
import { PlatformSegmentedTabs } from '../common/PlatformSegmentedTabs';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformTagEditor } from '../common/PlatformTagEditor';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
|
||||
import { UnifiedConfirmDialog } from '../common/UnifiedConfirmDialog';
|
||||
import PuzzleHistoryAssetPickerDialog from '../unified-creation/shared/PuzzleHistoryAssetPickerDialog';
|
||||
import {
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
type PuzzleImageModelId,
|
||||
} from '../unified-creation/shared/puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from '../unified-creation/shared/PuzzleImageModelPicker';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleResultViewProps = {
|
||||
session: PuzzleAgentSessionSnapshot;
|
||||
@@ -271,7 +283,8 @@ function mergeDraftEditStateWithIncomingState(
|
||||
selectedCandidateId: incomingLevel.selectedCandidateId,
|
||||
coverImageSrc: incomingLevel.coverImageSrc,
|
||||
coverAssetId: incomingLevel.coverAssetId,
|
||||
pictureReference: incomingLevel.pictureReference ?? level.pictureReference,
|
||||
pictureReference:
|
||||
incomingLevel.pictureReference ?? level.pictureReference,
|
||||
uiBackgroundPrompt:
|
||||
incomingLevel.uiBackgroundPrompt ?? level.uiBackgroundPrompt,
|
||||
uiBackgroundImageSrc:
|
||||
@@ -396,32 +409,33 @@ function PuzzleResultHeader({
|
||||
}) {
|
||||
const autoSaveBadge =
|
||||
autoSaveState === 'saving' ? (
|
||||
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
<PlatformPillBadge tone="warning" size="xs" className="px-3 py-1">
|
||||
保存中
|
||||
</div>
|
||||
</PlatformPillBadge>
|
||||
) : autoSaveState === 'saved' ? (
|
||||
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
<PlatformPillBadge tone="success" size="xs" className="px-3 py-1">
|
||||
已自动保存
|
||||
</div>
|
||||
</PlatformPillBadge>
|
||||
) : autoSaveState === 'error' ? (
|
||||
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
|
||||
<PlatformPillBadge tone="danger" size="xs" className="px-3 py-1">
|
||||
保存失败
|
||||
</div>
|
||||
</PlatformPillBadge>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
className="min-h-0 self-start py-1.5 text-[11px]"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
{autoSaveBadge}
|
||||
</div>
|
||||
);
|
||||
@@ -435,23 +449,12 @@ function PuzzleResultTabs({
|
||||
onChange: (tab: PuzzleResultTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
{PUZZLE_RESULT_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`min-h-10 rounded-[1rem] px-3 text-sm font-bold transition ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
|
||||
: 'text-[var(--platform-text-base)] hover:bg-white/60'
|
||||
}`}
|
||||
aria-pressed={activeTab === tab.id}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PlatformSegmentedTabs
|
||||
items={PUZZLE_RESULT_TABS}
|
||||
activeId={activeTab}
|
||||
onChange={onChange}
|
||||
className="mb-3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -468,144 +471,20 @@ function PuzzleThemeTagEditor({
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateTags: () => void;
|
||||
}) {
|
||||
const [newTagText, setNewTagText] = useState('');
|
||||
const [isAddingTag, setIsAddingTag] = useState(false);
|
||||
|
||||
const addTags = () => {
|
||||
const nextTags = normalizeThemeTagInput(newTagText);
|
||||
if (nextTags.length <= 0) {
|
||||
setIsAddingTag(false);
|
||||
setNewTagText('');
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({
|
||||
...editState,
|
||||
themeTags: [...new Set([...editState.themeTags, ...nextTags])],
|
||||
});
|
||||
setNewTagText('');
|
||||
setIsAddingTag(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标签
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={onGenerateTags}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="AI生成作品标签"
|
||||
title="AI生成作品标签"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{!isAddingTag ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsAddingTag(true)}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="新增作品标签"
|
||||
title="新增作品标签"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{editState.themeTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1.5 text-xs font-semibold text-amber-700"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...editState,
|
||||
themeTags: editState.themeTags.filter(
|
||||
(currentTag) => currentTag !== tag,
|
||||
),
|
||||
});
|
||||
}}
|
||||
className="rounded-full text-amber-800/70 transition hover:text-amber-950 disabled:opacity-45"
|
||||
aria-label={`删除标签 ${tag}`}
|
||||
title="删除标签"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{editState.themeTags.length <= 0 ? (
|
||||
<span className="text-sm text-[var(--platform-text-soft)]">
|
||||
暂无标签
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isAddingTag ? (
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2 sm:flex-row">
|
||||
<input
|
||||
autoFocus
|
||||
value={newTagText}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setNewTagText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addTags();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setIsAddingTag(false);
|
||||
setNewTagText('');
|
||||
}
|
||||
}}
|
||||
className="min-h-10 flex-1 rounded-[0.8rem] border border-transparent bg-white/92 px-3 text-sm text-[var(--platform-text-strong)] outline-none"
|
||||
placeholder="输入新标签"
|
||||
aria-label="新题材标签"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={addTags}
|
||||
className="platform-button platform-button--primary min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setIsAddingTag(false);
|
||||
setNewTagText('');
|
||||
}}
|
||||
className="platform-button platform-button--ghost min-h-10 flex-1 px-4 py-2 text-sm sm:flex-none"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<PlatformTagEditor
|
||||
title="作品标签"
|
||||
radius="lg"
|
||||
padding="lg"
|
||||
tags={editState.themeTags}
|
||||
disabled={isBusy}
|
||||
error={error}
|
||||
addLabel="新增作品标签"
|
||||
generateLabel="AI生成作品标签"
|
||||
parseInput={normalizeThemeTagInput}
|
||||
onChange={(themeTags) => onChange({ ...editState, themeTags })}
|
||||
onGenerate={onGenerateTags}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -672,7 +551,8 @@ function PuzzleLevelDetailDialog({
|
||||
? level.levelName || draft.workTitle || '拼图关卡'
|
||||
: '拼图参考图';
|
||||
const shouldShowReferenceMeta = Boolean(
|
||||
effectiveReferenceImageSrc && displayImageSrc !== effectiveReferenceImageSrc,
|
||||
effectiveReferenceImageSrc &&
|
||||
displayImageSrc !== effectiveReferenceImageSrc,
|
||||
);
|
||||
const generationProgress = resolvePuzzleLevelGenerationProgress(
|
||||
level,
|
||||
@@ -733,7 +613,9 @@ function PuzzleLevelDetailDialog({
|
||||
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
|
||||
})),
|
||||
);
|
||||
setPromptReferenceImages((current) => [...current, ...images].slice(0, 5));
|
||||
setPromptReferenceImages((current) =>
|
||||
[...current, ...images].slice(0, 5),
|
||||
);
|
||||
setReferenceImageError(
|
||||
files.length > remainingSlots ? '参考图最多上传 5 张。' : null,
|
||||
);
|
||||
@@ -770,14 +652,11 @@ function PuzzleLevelDetailDialog({
|
||||
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
{level.levelName || '关卡详情'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformIconButton
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
className="platform-icon-button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
@@ -785,18 +664,21 @@ function PuzzleLevelDetailDialog({
|
||||
<section className="grid gap-2 pb-4 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:items-center">
|
||||
<label
|
||||
htmlFor={`puzzle-level-name-${level.levelId}`}
|
||||
className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]"
|
||||
className="contents"
|
||||
>
|
||||
关卡名称
|
||||
<PlatformFieldLabel variant="section">
|
||||
关卡名称
|
||||
</PlatformFieldLabel>
|
||||
</label>
|
||||
<input
|
||||
<PlatformTextField
|
||||
id={`puzzle-level-name-${level.levelId}`}
|
||||
value={level.levelName}
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
onLevelChange({ ...level, levelName: event.target.value })
|
||||
}
|
||||
className="w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
size="lg"
|
||||
density="roomy"
|
||||
aria-label="关卡名称"
|
||||
/>
|
||||
</section>
|
||||
@@ -814,18 +696,16 @@ function PuzzleLevelDetailDialog({
|
||||
canUploadPromptReferences={!effectiveReferenceImageSrc}
|
||||
mainImageMeta={
|
||||
shouldShowReferenceMeta ? (
|
||||
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={effectiveReferenceImageSrc}
|
||||
alt="拼图参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{referenceImageLabel || '已选择参考图'}
|
||||
</div>
|
||||
</div>
|
||||
<PlatformUploadPreviewCard
|
||||
layout="inline"
|
||||
imageSrc={effectiveReferenceImageSrc}
|
||||
imageAlt="拼图参考图"
|
||||
caption={referenceImageLabel || '已选择参考图'}
|
||||
removeLabel="移除参考图"
|
||||
resolveAsset
|
||||
className="bg-white/72 py-3"
|
||||
imageShellClassName="rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
mainImageInputId={`puzzle-level-reference-upload-${level.levelId}`}
|
||||
@@ -854,7 +734,8 @@ function PuzzleLevelDetailDialog({
|
||||
submitDisabled={
|
||||
isBusy ||
|
||||
generationProgress.isGenerating ||
|
||||
(!level.pictureDescription.trim() && !effectiveReferenceImageSrc)
|
||||
(!level.pictureDescription.trim() &&
|
||||
!effectiveReferenceImageSrc)
|
||||
}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
@@ -864,7 +745,7 @@ function PuzzleLevelDetailDialog({
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferenceUpload: '上传描述参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
@@ -906,83 +787,50 @@ function PuzzleLevelDetailDialog({
|
||||
|
||||
<div className="shrink-0 space-y-3 border-t border-[var(--platform-subpanel-border)] bg-[var(--platform-page-fill)] px-5 py-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
|
||||
{onStartTestRun && hasFormalImage ? (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={isBusy}
|
||||
onClick={() => onStartTestRun(level.levelId)}
|
||||
className={`platform-button platform-button--secondary w-full ${isBusy ? 'opacity-55' : ''}`}
|
||||
tone="secondary"
|
||||
fullWidth
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
关卡测试
|
||||
</span>
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
|
||||
{generationProgress.isGenerating ? (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="画面生成进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={generationProgress.progressPercent}
|
||||
className="platform-progress-track relative h-12 overflow-hidden rounded-full"
|
||||
<PlatformProgressBar
|
||||
value={generationProgress.progressPercent}
|
||||
size="lg"
|
||||
ariaLabel="画面生成进度"
|
||||
fillClassName="bg-amber-600"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
|
||||
style={{ width: `${generationProgress.progressPercent}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
预计剩余 {generationProgress.secondsLeft} 秒
|
||||
</div>
|
||||
</div>
|
||||
</PlatformProgressBar>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isCostConfirmOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
|
||||
onClick={() => setIsCostConfirmOpen(false)}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="确认消耗泥点"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
确认消耗泥点
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 text-sm font-semibold text-[var(--platform-text-base)]">
|
||||
消耗 {PUZZLE_IMAGE_GENERATION_POINT_COST} 泥点
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--ghost min-h-10 px-4 py-2 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || generationProgress.isGenerating}
|
||||
onClick={executeGeneration}
|
||||
className={`platform-button platform-button--primary min-h-10 px-5 py-2 text-sm ${isBusy || generationProgress.isGenerating ? 'opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<UnifiedConfirmDialog
|
||||
open={isCostConfirmOpen}
|
||||
title="确认消耗泥点"
|
||||
onClose={() => setIsCostConfirmOpen(false)}
|
||||
onConfirm={executeGeneration}
|
||||
confirmLabel="确定"
|
||||
confirmDisabled={isBusy || generationProgress.isGenerating}
|
||||
showCancel
|
||||
portal={false}
|
||||
overlayClassName="absolute z-20 bg-black/45"
|
||||
panelClassName="platform-remap-surface rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
|
||||
>
|
||||
<div className="font-semibold">
|
||||
消耗 {PUZZLE_IMAGE_GENERATION_POINT_COST} 泥点
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedConfirmDialog>
|
||||
|
||||
{isHistoryPickerOpen ? (
|
||||
<PuzzleHistoryAssetPickerDialog
|
||||
@@ -1054,63 +902,65 @@ function PuzzlePublishDialog({
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
发布拼图作品
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformIconButton
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
className="platform-icon-button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
label="关闭"
|
||||
icon={<X className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_14rem]">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
<PlatformFieldLabel variant="section">
|
||||
发布检查
|
||||
</div>
|
||||
</PlatformFieldLabel>
|
||||
{actionError ? (
|
||||
<div className="platform-banner platform-banner--danger text-sm leading-6">
|
||||
<PlatformStatusMessage tone="error" surface="platform">
|
||||
{actionError}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : publishReady ? (
|
||||
<div className="space-y-2">
|
||||
<div className="platform-banner platform-banner--success text-sm leading-6">
|
||||
<PlatformStatusMessage tone="success" surface="platform">
|
||||
当前作品已满足发布条件。
|
||||
</div>
|
||||
<div className="platform-banner platform-banner--warning text-sm font-semibold leading-6">
|
||||
</PlatformStatusMessage>
|
||||
<PlatformStatusMessage
|
||||
tone="warning"
|
||||
surface="platform"
|
||||
className="font-semibold"
|
||||
>
|
||||
消耗 {PUZZLE_PUBLISH_POINT_COST} 泥点
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockers.map((blocker, index) => (
|
||||
<div
|
||||
<PlatformStatusMessage
|
||||
key={`puzzle-publish-blocker-${index}-${blocker}`}
|
||||
className="platform-banner platform-banner--warning text-sm leading-6"
|
||||
tone="warning"
|
||||
surface="platform"
|
||||
>
|
||||
{blocker}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
<PlatformFieldLabel variant="section">
|
||||
封面关卡
|
||||
</div>
|
||||
<div className="aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
|
||||
{formalImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={formalImageSrc}
|
||||
refreshKey={imageRefreshKey}
|
||||
alt={primaryLevel?.levelName || editState.workTitle}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</PlatformFieldLabel>
|
||||
<PlatformMediaFrame
|
||||
src={formalImageSrc}
|
||||
refreshKey={imageRefreshKey}
|
||||
alt={primaryLevel?.levelName || editState.workTitle}
|
||||
fallbackLabel="封面关卡"
|
||||
fallbackContent={<span className="sr-only">封面关卡</span>}
|
||||
aspect="square"
|
||||
surface="soft"
|
||||
className="rounded-[1.15rem]"
|
||||
/>
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{editState.workTitle}
|
||||
</div>
|
||||
@@ -1119,23 +969,17 @@ function PuzzlePublishDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="platform-button platform-button--ghost"
|
||||
>
|
||||
<PlatformActionButton onClick={onClose} tone="ghost">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
onClick={onPublish}
|
||||
disabled={!publishReady || isBusy}
|
||||
className={`platform-button platform-button--primary ${!publishReady || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isBusy
|
||||
? '发布中...'
|
||||
: `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -1173,16 +1017,29 @@ function PuzzleCreativeDraftEditBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel mb-3 rounded-[1.35rem] p-3 sm:p-4">
|
||||
<PlatformSubpanel
|
||||
as="section"
|
||||
radius="lg"
|
||||
padding="sm"
|
||||
className="mb-3 sm:p-4"
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="mb-1 hidden h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/72 text-[var(--platform-text-base)] sm:inline-flex">
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageSquareText className="h-4 w-4" />
|
||||
)}
|
||||
<span className="mb-1 hidden sm:inline-flex">
|
||||
<PlatformIconBadge
|
||||
tone="soft"
|
||||
size="sm"
|
||||
className="bg-white/72 text-[var(--platform-text-base)] shadow-none"
|
||||
icon={
|
||||
isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageSquareText className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<textarea
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
value={instruction}
|
||||
disabled={isBusy}
|
||||
rows={2}
|
||||
@@ -1193,25 +1050,33 @@ function PuzzleCreativeDraftEditBar({
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 flex-1 resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
|
||||
size="xs"
|
||||
density="roomy"
|
||||
className="min-h-11 flex-1"
|
||||
placeholder="让 Agent 调整标题、标签或关卡描述"
|
||||
aria-label="智能修订拼图草稿"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={!canSubmit}
|
||||
onClick={submit}
|
||||
className="platform-button platform-button--secondary min-h-11 px-4 py-2 text-sm"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
className="min-h-11"
|
||||
>
|
||||
{isBusy ? '修改中' : '修改'}
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-[1rem] text-sm leading-6">
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="md"
|
||||
className="mt-3 rounded-[1rem]"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
</section>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1249,44 +1114,55 @@ function PuzzleLevelListTab({
|
||||
generationNowMs,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={level.levelId}
|
||||
className="platform-subpanel overflow-hidden rounded-[1.35rem] p-0"
|
||||
radius="lg"
|
||||
padding="none"
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenLevel(level.levelId)}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
{imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={imageSrc}
|
||||
refreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
alt={displayLevelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
|
||||
暂无正式图
|
||||
</div>
|
||||
)}
|
||||
{generationProgress.isGenerating ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-1.5 text-xs font-black text-[var(--platform-text-strong)] shadow-sm">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-600" />
|
||||
生成中
|
||||
<PlatformMediaFrame
|
||||
src={imageSrc}
|
||||
refreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
alt={displayLevelName}
|
||||
fallbackLabel="暂无正式图"
|
||||
aspect="standard"
|
||||
surface="none"
|
||||
className="rounded-none"
|
||||
fallbackClassName="tracking-normal text-[var(--platform-text-soft)]"
|
||||
previewOverlay={
|
||||
generationProgress.isGenerating ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/68 backdrop-blur-[2px]">
|
||||
<PlatformPillBadge
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
icon={
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-amber-600" />
|
||||
}
|
||||
className="gap-2 border-white/80 bg-white/94 py-1.5 text-[var(--platform-text-strong)] shadow-sm"
|
||||
>
|
||||
生成中
|
||||
</PlatformPillBadge>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className="space-y-1 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
<span>第{index + 1}关</span>
|
||||
{generationProgress.isGenerating ? (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 tracking-normal text-amber-700">
|
||||
<PlatformPillBadge
|
||||
tone="warning"
|
||||
size="xs"
|
||||
className="border-transparent bg-amber-100 tracking-normal text-amber-700"
|
||||
>
|
||||
生成中
|
||||
</span>
|
||||
</PlatformPillBadge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1299,27 +1175,25 @@ function PuzzleLevelListTab({
|
||||
>
|
||||
{displayLevelName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<PlatformIconButton
|
||||
disabled={isBusy || editState.levels.length <= 1}
|
||||
onClick={() => onDeleteLevel(level.levelId)}
|
||||
className="platform-icon-button h-9 w-9 shrink-0"
|
||||
aria-label={`删除关卡 ${displayLevelName}`}
|
||||
label={`删除关卡 ${displayLevelName}`}
|
||||
title="删除关卡"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
className="h-9 w-9 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
disabled={isBusy}
|
||||
onClick={onAddLevel}
|
||||
className="platform-button platform-button--secondary w-full"
|
||||
tone="secondary"
|
||||
fullWidth
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -1328,7 +1202,7 @@ function PuzzleLevelListTab({
|
||||
<span className="mt-1 block text-[11px] font-semibold leading-none text-[var(--platform-text-soft)]">
|
||||
获得更多积分激励
|
||||
</span>
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1348,36 +1222,34 @@ function PuzzleWorkInfoTab({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品名称
|
||||
</div>
|
||||
<input
|
||||
<PlatformSubpanel title="作品名称" radius="lg" padding="lg">
|
||||
<PlatformTextField
|
||||
value={editState.workTitle}
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
onChange({ ...editState, workTitle: event.target.value })
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
size="lg"
|
||||
className="mt-2"
|
||||
aria-label="作品名称"
|
||||
/>
|
||||
</section>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品描述
|
||||
</div>
|
||||
<textarea
|
||||
<PlatformSubpanel title="作品描述" radius="lg" padding="lg">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
value={editState.workDescription}
|
||||
disabled={isBusy}
|
||||
rows={6}
|
||||
onChange={(event) =>
|
||||
onChange({ ...editState, workDescription: event.target.value })
|
||||
}
|
||||
className="mt-3 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
size="md"
|
||||
density="roomy"
|
||||
className="mt-3"
|
||||
aria-label="作品描述"
|
||||
/>
|
||||
</section>
|
||||
</PlatformSubpanel>
|
||||
|
||||
<PuzzleThemeTagEditor
|
||||
editState={editState}
|
||||
@@ -1422,32 +1294,29 @@ function PuzzleResultActionBar({
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
{onStartTestRun ? (
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={onStartTestRun}
|
||||
disabled={isBusy || !canStartTestRun}
|
||||
className={`platform-button platform-button--ghost ${isBusy || !canStartTestRun ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
tone="ghost"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
试玩
|
||||
</span>
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
<PlatformActionButton
|
||||
onClick={() => {
|
||||
setHasAttemptedPublish(false);
|
||||
setShowPublishDialog(true);
|
||||
}}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--primary ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
发布
|
||||
</span>
|
||||
</button>
|
||||
</PlatformActionButton>
|
||||
|
||||
{showPublishDialog ? (
|
||||
<PuzzlePublishDialog
|
||||
@@ -1527,11 +1396,10 @@ export function PuzzleResultView({
|
||||
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
|
||||
mergedState.levels.forEach((level) => {
|
||||
if (level.generationStatus === 'generating') {
|
||||
nextRuntimes[level.levelId] =
|
||||
current[level.levelId] ?? {
|
||||
startedAtMs: Date.now(),
|
||||
estimateSeconds: resolvePuzzleLevelGenerationEstimateSeconds(),
|
||||
};
|
||||
nextRuntimes[level.levelId] = current[level.levelId] ?? {
|
||||
startedAtMs: Date.now(),
|
||||
estimateSeconds: resolvePuzzleLevelGenerationEstimateSeconds(),
|
||||
};
|
||||
}
|
||||
});
|
||||
return nextRuntimes;
|
||||
@@ -1625,8 +1493,7 @@ export function PuzzleResultView({
|
||||
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc?.trim() || null,
|
||||
uiSpritesheetImageObjectKey:
|
||||
level.uiSpritesheetImageObjectKey?.trim() || null,
|
||||
levelBackgroundImageSrc:
|
||||
level.levelBackgroundImageSrc?.trim() || null,
|
||||
levelBackgroundImageSrc: level.levelBackgroundImageSrc?.trim() || null,
|
||||
levelBackgroundImageObjectKey:
|
||||
level.levelBackgroundImageObjectKey?.trim() || null,
|
||||
generationStatus: level.generationStatus || 'idle',
|
||||
@@ -1695,9 +1562,14 @@ export function PuzzleResultView({
|
||||
if (!draft || !editState || !syncedDraft) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
radius="sm"
|
||||
padding="lg"
|
||||
className="text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
还没有可编辑的拼图草稿
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1822,9 +1694,14 @@ export function PuzzleResultView({
|
||||
</div>
|
||||
|
||||
{autoSaveError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="md"
|
||||
className="mt-3"
|
||||
>
|
||||
{autoSaveError}
|
||||
</div>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
<PuzzleResultActionBar
|
||||
@@ -1836,9 +1713,7 @@ export function PuzzleResultView({
|
||||
publishReady={publishState.publishReady}
|
||||
publishBlockers={publishState.blockers}
|
||||
onStartTestRun={
|
||||
onStartTestRun
|
||||
? () => onStartTestRun(syncedDraft)
|
||||
: undefined
|
||||
onStartTestRun ? () => onStartTestRun(syncedDraft) : undefined
|
||||
}
|
||||
onPublish={() => {
|
||||
if (!publishState.publishReady) {
|
||||
|
||||
Reference in New Issue
Block a user