收口前端平台组件库能力

新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
This commit is contained in:
2026-06-10 10:24:18 +08:00
parent a4ee6ff698
commit 1ad25e30f8
226 changed files with 23364 additions and 7825 deletions

View File

@@ -1,8 +1,14 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { SquareHoleResultView } from './SquareHoleResultView';
@@ -16,6 +22,11 @@ vi.mock('../../services/square-hole-works', () => ({
updateSquareHoleWork: vi.fn(),
}));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
function createProfile(): SquareHoleWorkProfile {
return {
profileId: 'profile-1',
@@ -88,6 +99,14 @@ test('square hole result view exposes test run and publish actions', async () =>
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
await user.clear(screen.getByLabelText('游戏名称'));
await user.type(screen.getByLabelText('游戏名称'), '几何新挑战');
const shapeNameInput = screen.getByLabelText('方块名称');
const holePromptInput = screen.getByLabelText('洞口 1图片提示词');
await user.clear(shapeNameInput);
await user.type(shapeNameInput, '圆形钥匙');
await user.clear(holePromptInput);
await user.type(holePromptInput, '圆形洞口贴图');
await user.click(screen.getByRole('button', { name: '试玩' }));
await user.click(screen.getByRole('button', { name: '发布' }));
await user.click(screen.getByRole('button', { name: '返回' }));
@@ -95,8 +114,217 @@ test('square hole result view exposes test run and publish actions', async () =>
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledTimes(1);
});
expect(mockUpdateSquareHoleWork).toHaveBeenCalledWith(
'profile-1',
expect.objectContaining({
gameName: '几何新挑战',
shapeOptions: [expect.objectContaining({ label: '圆形钥匙' })],
holeOptions: [expect.objectContaining({ imagePrompt: '圆形洞口贴图' })],
}),
);
await waitFor(() => {
expect(onPublished).toHaveBeenCalledTimes(1);
});
expect(onBack).toHaveBeenCalledTimes(1);
});
test('square hole autosave badges reuse PlatformPillBadge chrome', async () => {
vi.useFakeTimers();
const { updateSquareHoleWork } = await import(
'../../services/square-hole-works'
);
vi.mocked(updateSquareHoleWork).mockResolvedValue({
item: createProfile(),
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText('游戏名称'), {
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',
);
});
test('square hole autosave failure badge uses PlatformPillBadge danger chrome', async () => {
vi.useFakeTimers();
const { updateSquareHoleWork } = await import(
'../../services/square-hole-works'
);
vi.mocked(updateSquareHoleWork).mockRejectedValue(new Error('保存失败'));
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
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('square hole result view uses PlatformFieldLabel for section labels', () => {
const { container } = render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
for (const label of ['游戏名称', '题材主题', '形状选项', '洞口选项']) {
expect(screen.getByText(label).className).toContain('tracking-[0.18em]');
}
const panels = Array.from(
container.querySelectorAll('section.platform-subpanel'),
);
expect(panels).toHaveLength(4);
for (const panel of panels) {
expect(panel.className).toContain('rounded-[1.5rem]');
expect(panel.className).toContain('sm:p-5');
}
const optionCards = Array.from(
container.querySelectorAll('div.bg-white\\/72'),
).filter((element) => element.className.includes('p-3'));
expect(optionCards).toHaveLength(2);
for (const card of optionCards) {
expect(card.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(card.className).toContain('rounded-[1rem]');
expect(card.className).toContain('p-3');
}
for (const thumbnailButton of [
screen.getByRole('button', { name: '查看方块贴图' }),
screen.getByRole('button', { name: '查看洞口 1贴图' }),
]) {
expect(thumbnailButton.className).toContain('bg-white/82');
expect(thumbnailButton.className).toContain('hover:bg-white');
expect(thumbnailButton.className).toContain('disabled:cursor-not-allowed');
}
for (const deleteButton of [
screen.getByRole('button', { name: '删除形状选项' }),
screen.getByRole('button', { name: '删除洞口选项' }),
]) {
expect(deleteButton.className).toContain('platform-icon-button');
expect(deleteButton.className).toContain('p-2');
}
});
test('square hole shape and hole thumbnails use platform media frames', () => {
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
for (const thumbnailButton of [
screen.getByRole('button', { name: '查看方块贴图' }),
screen.getByRole('button', { name: '查看洞口 1贴图' }),
]) {
const mediaFrame = thumbnailButton.querySelector('div.relative');
expect(mediaFrame?.className).toContain('aspect-square');
expect(mediaFrame?.className).toContain('bg-transparent');
expect(mediaFrame?.className).toContain('rounded-none');
expect(mediaFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
}
});
test('square hole image dialog reuses platform media frame', async () => {
const user = userEvent.setup();
const { squareHoleAssetClient } = await import(
'../../services/square-hole-works'
);
vi.mocked(squareHoleAssetClient.listHistoryAssets).mockResolvedValue([]);
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '查看封面图' }));
const dialog = await screen.findByRole('dialog', { name: '封面图查看' });
const image = screen.getByRole('img', { name: '封面图' });
const mediaFrame = image.closest('div.relative');
expect(dialog).toBeTruthy();
expect(mediaFrame?.className).toContain('aspect-[4/3]');
expect(mediaFrame?.className).toContain('rounded-[1.35rem]');
expect(mediaFrame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(mediaFrame?.className).toContain('bg-[var(--platform-subpanel-fill)]');
});
test('square hole cover and background buttons use platform media frames', () => {
render(
<SquareHoleResultView
profile={createProfile()}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
const coverFrame = screen
.getByRole('button', { name: '查看封面图' })
.querySelector('div.relative');
const backgroundFrame = screen
.getByRole('button', { name: '查看背景图' })
.querySelector('div.relative');
expect(coverFrame?.className).toContain('aspect-[4/3]');
expect(coverFrame?.className).toContain('rounded-none');
expect(coverFrame?.className).toContain('bg-transparent');
expect(coverFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
expect(backgroundFrame?.className).toContain('aspect-[16/9]');
expect(backgroundFrame?.className).toContain('rounded-none');
expect(backgroundFrame?.className).toContain('bg-transparent');
expect(backgroundFrame?.className).not.toContain(
'bg-[var(--platform-subpanel-fill)]',
);
});

View File

@@ -9,7 +9,6 @@ import {
RefreshCw,
Send,
Trash2,
X,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -30,7 +29,20 @@ import {
updateSquareHoleWork,
} from '../../services/square-hole-works';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatGrid } from '../common/PlatformStatGrid';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import {
PlatformSelectField,
PlatformTextField,
} from '../common/PlatformTextField';
type SquareHoleResultViewProps = {
profile: SquareHoleWorkProfile;
@@ -440,32 +452,31 @@ function SquareHoleResultHeader({
}) {
const badge =
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 gap-1.5 px-3 py-1.5 text-[11px]"
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
<ArrowLeft className="h-3.5 w-3.5" />
</PlatformActionButton>
{badge}
</div>
);
@@ -555,35 +566,33 @@ function SquareHoleImageSlotDialog({
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
{slot.title}
</div>
<button
type="button"
<PlatformModalCloseButton
onClick={onClose}
aria-label="关闭"
className="platform-icon-button"
>
<X className="h-4 w-4" />
</button>
label="关闭"
variant="platformIcon"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,0.78fr)]">
<div className="space-y-3">
<div className="aspect-[4/3] overflow-hidden rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
{currentImageSrc ? (
<ResolvedAssetImage
src={currentImageSrc}
alt={slot.title}
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-[var(--platform-text-soft)]">
<Images className="h-10 w-10" />
</div>
)}
</div>
<PlatformMediaFrame
src={currentImageSrc}
alt={slot.title}
fallbackLabel={`${slot.title}占位图`}
fallbackContent={<Images className="h-10 w-10" />}
aspect="standard"
surface="plain"
className="rounded-[1.35rem]"
fallbackClassName="tracking-normal text-[var(--platform-text-soft)]"
/>
<div className="grid gap-2 sm:grid-cols-2">
<label
className={`platform-button platform-button--ghost flex min-h-11 cursor-pointer items-center justify-center gap-2 px-4 py-3 text-sm ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
<PlatformActionButton
asChild="label"
tone="ghost"
size="md"
aria-disabled={isBusy}
className={`min-h-11 cursor-pointer gap-2 px-4 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
<ImagePlus className="h-4 w-4" />
@@ -594,12 +603,13 @@ function SquareHoleImageSlotDialog({
disabled={isBusy}
onChange={(event) => onUpload(slot, event)}
/>
</label>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
onClick={onRegenerateImages}
disabled={!canRegenerateImages || isBusy}
className={`platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3 text-sm ${!canRegenerateImages || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
tone="secondary"
size="md"
className="min-h-11 gap-2 px-4"
>
{isRegeneratingImages ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -607,63 +617,34 @@ function SquareHoleImageSlotDialog({
<RefreshCw className="h-4 w-4" />
)}
AI生成图片
</button>
</PlatformActionButton>
</div>
</div>
<div className="min-h-0 space-y-3">
<div className="flex items-center gap-2 text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel
variant="section"
className="flex items-center gap-2"
>
<Images className="h-3.5 w-3.5" />
</div>
</PlatformFieldLabel>
{error ? (
<div className="platform-banner platform-banner--danger text-sm leading-6">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.25rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
...
</div>
) : null}
{!isLoading && !error && assets.length <= 0 ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.25rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
</div>
) : null}
{!isLoading && assets.length > 0 ? (
<div className="grid max-h-[24rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-2 xl:grid-cols-3">
{assets.map((asset) => (
<button
key={asset.assetObjectId}
type="button"
disabled={isBusy}
onClick={() => onSelectHistory(asset)}
className={`overflow-hidden rounded-[1.1rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.ownerLabel || '历史图片'}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1 px-3 py-3">
<div className="truncate text-xs font-black text-[var(--platform-text-strong)]">
{asset.ownerLabel || '未记录账号'}
</div>
<div className="text-[11px] leading-4 text-[var(--platform-text-base)]">
{formatHistoryAssetDate(asset.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
<PlatformAssetPickerGrid
items={assets}
isLoading={isLoading}
error={error}
loadingLabel="读取中..."
emptyLabel="暂无历史图片"
disabled={isBusy}
getKey={(asset) => asset.assetObjectId}
getImageSrc={(asset) => asset.imageSrc}
getImageAlt={(asset) => asset.ownerLabel || '历史图片'}
getTitle={(asset) => asset.ownerLabel || '未记录账号'}
getSubtitle={(asset) => formatHistoryAssetDate(asset.createdAt)}
onSelect={onSelectHistory}
gridClassName="grid max-h-[24rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-2 xl:grid-cols-3"
/>
</div>
</div>
</div>
@@ -855,7 +836,9 @@ export function SquareHoleResultView({
{
visualAssetSlot: activeImageSlot.kind,
visualAssetOptionId:
activeImageSlot.shapeOptionId ?? activeImageSlot.holeOptionId ?? null,
activeImageSlot.shapeOptionId ??
activeImageSlot.holeOptionId ??
null,
},
);
setEditState(createEditState(item));
@@ -894,7 +877,9 @@ export function SquareHoleResultView({
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '套用历史图片失败。',
caughtError instanceof Error
? caughtError.message
: '套用历史图片失败。',
);
} finally {
setIsApplyingHistoryImage(false);
@@ -947,7 +932,7 @@ export function SquareHoleResultView({
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 lg:grid-cols-[minmax(17rem,0.72fr)_minmax(0,1fr)]">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<PlatformSubpanel radius="xl" padding="lg">
<button
type="button"
disabled={busy}
@@ -958,32 +943,36 @@ export function SquareHoleResultView({
assetKind: 'square_hole_cover_image',
})
}
className={`group block aspect-[4/3] w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_42%_30%,rgba(125,211,252,0.28),transparent_34%),linear-gradient(135deg,rgba(15,23,42,0.16),rgba(20,184,166,0.18))] text-left ${busy ? 'cursor-not-allowed opacity-70' : ''}`}
className={`group block w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_42%_30%,rgba(125,211,252,0.28),transparent_34%),linear-gradient(135deg,rgba(15,23,42,0.16),rgba(20,184,166,0.18))] text-left ${busy ? 'cursor-not-allowed opacity-70' : ''}`}
aria-label="查看封面图"
>
{editState.coverImageSrc ? (
<ResolvedAssetImage
src={editState.coverImageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-slate-700">
<ImagePlus className="h-10 w-10" />
</div>
)}
<PlatformMediaFrame
src={editState.coverImageSrc}
alt=""
fallbackLabel="封面图"
fallbackContent={<ImagePlus className="h-10 w-10" />}
aspect="standard"
surface="none"
className="rounded-none bg-transparent"
fallbackClassName="tracking-normal text-slate-700"
/>
</button>
<div className="mt-3 grid grid-cols-2 gap-2 text-center text-xs font-bold text-[var(--platform-text-base)]">
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{editState.shapeCountText || '-'}
</div>
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{(draft?.publishReady ?? profile.publishReady)
? '可发布'
: '草稿'}
</div>
</div>
<PlatformStatGrid
items={[
{ value: `${editState.shapeCountText || '-'}` },
{
value:
(draft?.publishReady ?? profile.publishReady)
? '可发布'
: '草稿',
},
]}
columns="two"
density="compact"
surface="plain"
className="mt-3"
itemClassName="border-0 bg-white/68"
/>
<button
type="button"
disabled={busy}
@@ -994,74 +983,71 @@ export function SquareHoleResultView({
assetKind: 'square_hole_background_image',
})
}
className={`mt-3 block aspect-[16/9] w-full overflow-hidden rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(15,23,42,0.12),rgba(34,197,94,0.16))] text-left ${busy ? 'cursor-not-allowed opacity-70' : ''}`}
className={`mt-3 block w-full overflow-hidden rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(15,23,42,0.12),rgba(34,197,94,0.16))] text-left ${busy ? 'cursor-not-allowed opacity-70' : ''}`}
aria-label="查看背景图"
>
{editState.backgroundImageSrc ? (
<ResolvedAssetImage
src={editState.backgroundImageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-slate-700">
<ImagePlus className="h-8 w-8" />
</div>
)}
<PlatformMediaFrame
src={editState.backgroundImageSrc}
alt=""
fallbackLabel="背景图"
fallbackContent={<ImagePlus className="h-8 w-8" />}
aspect="landscape"
surface="none"
className="rounded-none bg-transparent"
fallbackClassName="tracking-normal text-slate-700"
/>
</button>
</section>
</PlatformSubpanel>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<PlatformSubpanel radius="xl" padding="lg">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel variant="section">
</span>
<input
</PlatformFieldLabel>
<PlatformTextField
value={editState.gameName}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, gameName: 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"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
<PlatformTextField
value={editState.tagsText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, tagsText: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
className="mt-2"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={editState.summary}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, summary: event.target.value })
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
size="md"
className="mt-2"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel variant="section">
</span>
<input
</PlatformFieldLabel>
<PlatformTextField
value={editState.themeText}
disabled={busy}
onChange={(event) =>
@@ -1070,15 +1056,15 @@ export function SquareHoleResultView({
themeText: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
className="mt-2"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel variant="section">
</span>
<input
</PlatformFieldLabel>
<PlatformTextField
value={editState.twistRule}
disabled={busy}
onChange={(event) =>
@@ -1087,15 +1073,15 @@ export function SquareHoleResultView({
twistRule: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
className="mt-2"
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel variant="section">
</span>
<input
</PlatformFieldLabel>
<PlatformTextField
value={editState.backgroundPrompt}
disabled={busy}
onChange={(event) =>
@@ -1104,15 +1090,15 @@ export function SquareHoleResultView({
backgroundPrompt: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
className="mt-2"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel variant="section">
</span>
<input
</PlatformFieldLabel>
<PlatformTextField
value={editState.shapeCountText}
inputMode="numeric"
disabled={busy}
@@ -1122,20 +1108,18 @@ export function SquareHoleResultView({
shapeCountText: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
className="mt-2"
/>
</label>
</div>
</section>
</PlatformSubpanel>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5 lg:col-span-2">
<PlatformSubpanel radius="xl" padding="lg" className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel variant="section">
</div>
<button
type="button"
</PlatformFieldLabel>
<PlatformActionButton
disabled={busy}
onClick={() =>
setEditState((current) => ({
@@ -1149,22 +1133,31 @@ export function SquareHoleResultView({
],
}))
}
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
tone="ghost"
size="xs"
className="min-h-0 gap-1.5 px-3 py-1.5 text-[11px]"
>
<Plus className="h-3.5 w-3.5" />
</button>
</PlatformActionButton>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{editState.shapeOptions.map((option) => (
<div
<PlatformSubpanel
as="div"
key={option.optionId}
className="rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3"
surface="flat"
radius="sm"
padding="sm"
>
<div className="mb-3 flex items-start gap-3">
<button
type="button"
<PlatformSubpanel
as="button"
disabled={busy}
surface="flat"
radius="sm"
padding="none"
interactive
onClick={() =>
setActiveImageSlot({
kind: 'shape',
@@ -1173,24 +1166,26 @@ export function SquareHoleResultView({
shapeOptionId: option.optionId,
})
}
className={`relative grid h-20 w-20 shrink-0 place-items-center overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/82 text-slate-600 ${busy ? 'cursor-not-allowed opacity-70' : ''}`}
className="relative grid h-20 w-20 shrink-0 place-items-center overflow-hidden bg-white/82 text-slate-600"
aria-label={`查看${option.label || '形状'}贴图`}
>
{option.imageSrc ? (
<ResolvedAssetImage
src={option.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<ImagePlus className="h-6 w-6" />
)}
</button>
<PlatformMediaFrame
src={option.imageSrc}
alt=""
fallbackLabel={`${option.label || '形状'}贴图`}
fallbackContent={<ImagePlus className="h-6 w-6" />}
aspect="square"
surface="none"
className="h-full w-full rounded-none bg-transparent"
fallbackClassName="tracking-normal text-slate-600"
/>
</PlatformSubpanel>
<div className="min-w-0 flex-1 space-y-2">
<input
<PlatformTextField
aria-label={`${option.label || '形状'}名称`}
value={option.label}
disabled={busy}
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
@@ -1201,11 +1196,12 @@ export function SquareHoleResultView({
),
}))
}
className="w-full rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
<select
<PlatformSelectField
aria-label={`${option.label || '形状'}目标洞口`}
value={option.targetHoleId}
disabled={busy}
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
@@ -1216,7 +1212,6 @@ export function SquareHoleResultView({
),
}))
}
className="w-full rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
>
{editState.holeOptions.map((holeOption, holeIndex) => (
<option
@@ -1226,10 +1221,9 @@ export function SquareHoleResultView({
{holeOption.label.trim() || `洞口 ${holeIndex + 1}`}
</option>
))}
</select>
</PlatformSelectField>
</div>
<button
type="button"
<PlatformIconButton
disabled={busy || editState.shapeOptions.length <= 1}
onClick={() =>
setEditState((current) => ({
@@ -1239,16 +1233,19 @@ export function SquareHoleResultView({
),
}))
}
className="rounded-full p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
aria-label="删除形状选项"
>
<Trash2 className="h-4 w-4" />
</button>
label="删除形状选项"
icon={<Trash2 className="h-4 w-4" />}
className="p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
/>
</div>
<textarea
<PlatformTextField
variant="textarea"
aria-label={`${option.label || '形状'}图片提示词`}
value={option.imagePrompt}
disabled={busy}
rows={2}
size="xs"
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
@@ -1259,20 +1256,18 @@ export function SquareHoleResultView({
),
}))
}
className="w-full resize-none rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
/>
</div>
</PlatformSubpanel>
))}
</div>
</section>
</PlatformSubpanel>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5 lg:col-span-2">
<PlatformSubpanel radius="xl" padding="lg" className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<PlatformFieldLabel variant="section">
</div>
<button
type="button"
</PlatformFieldLabel>
<PlatformActionButton
disabled={busy}
onClick={() =>
setEditState((current) => ({
@@ -1283,22 +1278,31 @@ export function SquareHoleResultView({
],
}))
}
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
tone="ghost"
size="xs"
className="min-h-0 gap-1.5 px-3 py-1.5 text-[11px]"
>
<Plus className="h-3.5 w-3.5" />
</button>
</PlatformActionButton>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{editState.holeOptions.map((option) => (
<div
<PlatformSubpanel
as="div"
key={option.holeId}
className="rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3"
surface="flat"
radius="sm"
padding="sm"
>
<div className="mb-3 flex items-start gap-3">
<button
type="button"
<PlatformSubpanel
as="button"
disabled={busy}
surface="flat"
radius="sm"
padding="none"
interactive
onClick={() =>
setActiveImageSlot({
kind: 'hole',
@@ -1307,24 +1311,26 @@ export function SquareHoleResultView({
holeOptionId: option.holeId,
})
}
className={`relative grid h-20 w-20 shrink-0 place-items-center overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/82 text-slate-600 ${busy ? 'cursor-not-allowed opacity-70' : ''}`}
className="relative grid h-20 w-20 shrink-0 place-items-center overflow-hidden bg-white/82 text-slate-600"
aria-label={`查看${option.label || '洞口'}贴图`}
>
{option.imageSrc ? (
<ResolvedAssetImage
src={option.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<ImagePlus className="h-6 w-6" />
)}
</button>
<PlatformMediaFrame
src={option.imageSrc}
alt=""
fallbackLabel={`${option.label || '洞口'}贴图`}
fallbackContent={<ImagePlus className="h-6 w-6" />}
aspect="square"
surface="none"
className="h-full w-full rounded-none bg-transparent"
fallbackClassName="tracking-normal text-slate-600"
/>
</PlatformSubpanel>
<div className="min-w-0 flex-1 space-y-2">
<input
<PlatformTextField
aria-label={`${option.label || '洞口'}名称`}
value={option.label}
disabled={busy}
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
@@ -1335,12 +1341,15 @@ export function SquareHoleResultView({
),
}))
}
className="w-full rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
<textarea
<PlatformTextField
variant="textarea"
aria-label={`${option.label || '洞口'}图片提示词`}
value={option.imagePrompt}
disabled={busy}
rows={2}
size="xs"
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
@@ -1351,11 +1360,9 @@ export function SquareHoleResultView({
),
}))
}
className="w-full resize-none rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
/>
</div>
<button
type="button"
<PlatformIconButton
disabled={busy || editState.holeOptions.length <= 1}
onClick={() =>
setEditState((current) => {
@@ -1378,31 +1385,36 @@ export function SquareHoleResultView({
};
})
}
className="rounded-full p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
aria-label="删除洞口选项"
>
<Trash2 className="h-4 w-4" />
</button>
label="删除洞口选项"
icon={<Trash2 className="h-4 w-4" />}
className="p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
/>
</div>
</div>
</PlatformSubpanel>
))}
</div>
</section>
</PlatformSubpanel>
</div>
</div>
{displayError ? (
<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 rounded-2xl"
>
{displayError}
</div>
</PlatformStatusMessage>
) : null}
<div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
<button
type="button"
<PlatformActionButton
onClick={handleStartTestRun}
disabled={!canSubmit || busy}
className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
tone="ghost"
size="md"
className="min-h-11 gap-2 px-5"
>
{isStartingTestRun ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -1410,12 +1422,12 @@ export function SquareHoleResultView({
<Play className="h-4 w-4" />
)}
</button>
<button
type="button"
</PlatformActionButton>
<PlatformActionButton
onClick={handlePublish}
disabled={!canSubmit || busy}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
size="md"
className="min-h-11 gap-2 px-5"
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -1425,7 +1437,7 @@ export function SquareHoleResultView({
<Send className="h-4 w-4" />
)}
{profile.publicationStatus === 'published' ? '更新发布' : '发布'}
</button>
</PlatformActionButton>
</div>
{activeImageSlot ? (