继续收口工具弹窗与分段切换预设

新增 PlatformToolModalShell 承接白底工具弹窗壳层和固定可访问名称

新增 PlatformSegmentedTabPresets 沉淀频道下划线、创作 pill rail 与二列 option segment

迁移拼图、抓大鹅、历史素材弹窗和首页 / 作品架 / 充值切换的重复组件写法

同步 PlatformUiKit 文档和 Hermes 决策记录
This commit is contained in:
2026-06-11 16:32:56 +08:00
parent 7c47ad3358
commit ffcffef6d2
15 changed files with 848 additions and 621 deletions

View File

@@ -7,7 +7,6 @@ import {
Trash2,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
@@ -19,7 +18,6 @@ import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/co
import { updatePuzzleWork } from '../../services/puzzle-works';
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBackActionButton } from '../common/PlatformBackActionButton';
@@ -28,7 +26,6 @@ import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconBadge } from '../common/PlatformIconBadge';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformProgressBar } from '../common/PlatformProgressBar';
@@ -37,6 +34,7 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTagEditor } from '../common/PlatformTagEditor';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformToolModalShell } from '../common/PlatformToolModalShell';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import PuzzleHistoryAssetPickerDialog from '../unified-creation/shared/PuzzleHistoryAssetPickerDialog';
import {
@@ -513,7 +511,6 @@ function PuzzleLevelDetailDialog({
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
onStartTestRun?: (levelId: string) => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [referenceImageLabel, setReferenceImageLabel] = useState('');
const [referenceImageError, setReferenceImageError] = useState<string | null>(
@@ -622,164 +619,20 @@ function PuzzleLevelDetailDialog({
}
};
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[138] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="关卡详情"
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-[56rem] flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
{level.levelName || '关卡详情'}
</div>
<PlatformModalCloseButton
variant="platformIcon"
label="关闭关卡详情"
onClick={onClose}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="puzzle-level-detail-list divide-y divide-[var(--platform-subpanel-border)]">
<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="contents"
>
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
</label>
<PlatformTextField
id={`puzzle-level-name-${level.levelId}`}
value={level.levelName}
disabled={isBusy}
onChange={(event) =>
onLevelChange({ ...level, levelName: event.target.value })
}
size="lg"
density="roomy"
aria-label="关卡名称"
/>
</section>
<section className="pt-4">
<CreativeImageInputPanel
className="puzzle-level-detail-image-editor"
disabled={isBusy || generationProgress.isGenerating}
isSubmitting={generationProgress.isGenerating}
uploadedImageSrc={displayImageSrc}
uploadedImageAlt={displayImageAlt}
uploadedImageRefreshKey={`${imageRefreshKey}:${level.levelId}`}
canRemoveMainImage={Boolean(effectiveReferenceImageSrc)}
canToggleAiRedraw={Boolean(effectiveReferenceImageSrc)}
canUploadPromptReferences={!effectiveReferenceImageSrc}
mainImageMeta={
shouldShowReferenceMeta ? (
<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}`}
promptTextareaId={`puzzle-level-picture-description-${level.levelId}`}
prompt={level.pictureDescription}
promptLabel={
effectiveReferenceImageSrc
? '画面AI重绘要求提示词'
: '画面描述'
}
promptRows={7}
aiRedraw={aiRedraw}
promptReferenceImages={promptReferenceImages}
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={imageModel}
disabled={isBusy || generationProgress.isGenerating}
onChange={setImageModel}
/>
}
inputError={referenceImageError}
submitLabel={hasFormalImage ? '重新生成画面' : '生成画面'}
submitCostLabel={`消耗${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
submitDisabled={
isBusy ||
generationProgress.isGenerating ||
(!level.pictureDescription.trim() &&
!effectiveReferenceImageSrc)
}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传描述参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
history: '选择历史图片',
}}
onMainImageFileSelect={(file) => {
void handleReferenceImageFile(file);
}}
onMainImageRemove={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
setAiRedraw(true);
onLevelChange({ ...level, pictureReference: null });
}}
onAiRedrawChange={setAiRedraw}
onPromptChange={(value) =>
onLevelChange({
...level,
pictureDescription: value,
})
}
onPromptReferenceFilesSelect={(files) => {
void handlePromptReferenceImageFiles(files);
}}
onPromptReferenceRemove={(referenceId) => {
setPromptReferenceImages((current) =>
current.filter((image) => image.id !== referenceId),
);
setReferenceImageError(null);
}}
onHistoryClick={() => setIsHistoryPickerOpen(true)}
onSubmit={() => setIsCostConfirmOpen(true)}
/>
</section>
</div>
</div>
<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)]">
return (
<PlatformToolModalShell
open
title={level.levelName || '关卡详情'}
ariaLabel="关卡详情"
onClose={onClose}
closeLabel="关闭关卡详情"
zIndexClassName="z-[138]"
size="xl"
panelClassName="!max-h-[min(94vh,50rem)] !max-w-[56rem]"
titleClassName="truncate"
footerClassName="!block shrink-0 space-y-3 bg-[var(--platform-page-fill)] pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
footer={
<>
{onStartTestRun && hasFormalImage ? (
<PlatformActionButton
disabled={isBusy}
@@ -807,37 +660,160 @@ function PuzzleLevelDetailDialog({
</div>
</PlatformProgressBar>
) : null}
</div>
<PlatformMudPointConfirmDialog
open={isCostConfirmOpen}
points={PUZZLE_IMAGE_GENERATION_POINT_COST}
onClose={() => setIsCostConfirmOpen(false)}
onConfirm={executeGeneration}
confirmDisabled={isBusy || generationProgress.isGenerating}
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)]"
/>
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
onClose={() => setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setReferenceImageSrc(asset.imageSrc);
setReferenceImageLabel(
getPuzzleHistoryAssetReferenceLabel(asset.imageSrc),
);
setAiRedraw(true);
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
}}
</>
}
>
<div className="puzzle-level-detail-list divide-y divide-[var(--platform-subpanel-border)]">
<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="contents"
>
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
</label>
<PlatformTextField
id={`puzzle-level-name-${level.levelId}`}
value={level.levelName}
disabled={isBusy}
onChange={(event) =>
onLevelChange({ ...level, levelName: event.target.value })
}
size="lg"
density="roomy"
aria-label="关卡名称"
/>
) : null}
</section>
<section className="pt-4">
<CreativeImageInputPanel
className="puzzle-level-detail-image-editor"
disabled={isBusy || generationProgress.isGenerating}
isSubmitting={generationProgress.isGenerating}
uploadedImageSrc={displayImageSrc}
uploadedImageAlt={displayImageAlt}
uploadedImageRefreshKey={`${imageRefreshKey}:${level.levelId}`}
canRemoveMainImage={Boolean(effectiveReferenceImageSrc)}
canToggleAiRedraw={Boolean(effectiveReferenceImageSrc)}
canUploadPromptReferences={!effectiveReferenceImageSrc}
mainImageMeta={
shouldShowReferenceMeta ? (
<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}`}
promptTextareaId={`puzzle-level-picture-description-${level.levelId}`}
prompt={level.pictureDescription}
promptLabel={
effectiveReferenceImageSrc
? '画面AI重绘要求提示词'
: '画面描述'
}
promptRows={7}
aiRedraw={aiRedraw}
promptReferenceImages={promptReferenceImages}
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
imageLimitHint="图片≤6MB"
imageModelPicker={
<PuzzleImageModelPicker
value={imageModel}
disabled={isBusy || generationProgress.isGenerating}
onChange={setImageModel}
/>
}
inputError={referenceImageError}
submitLabel={hasFormalImage ? '重新生成画面' : '生成画面'}
submitCostLabel={`消耗${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
submitDisabled={
isBusy ||
generationProgress.isGenerating ||
(!level.pictureDescription.trim() &&
!effectiveReferenceImageSrc)
}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传描述参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
history: '选择历史图片',
}}
onMainImageFileSelect={(file) => {
void handleReferenceImageFile(file);
}}
onMainImageRemove={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
setAiRedraw(true);
onLevelChange({ ...level, pictureReference: null });
}}
onAiRedrawChange={setAiRedraw}
onPromptChange={(value) =>
onLevelChange({
...level,
pictureDescription: value,
})
}
onPromptReferenceFilesSelect={(files) => {
void handlePromptReferenceImageFiles(files);
}}
onPromptReferenceRemove={(referenceId) => {
setPromptReferenceImages((current) =>
current.filter((image) => image.id !== referenceId),
);
setReferenceImageError(null);
}}
onHistoryClick={() => setIsHistoryPickerOpen(true)}
onSubmit={() => setIsCostConfirmOpen(true)}
/>
</section>
</div>
</div>,
document.body,
<PlatformMudPointConfirmDialog
open={isCostConfirmOpen}
points={PUZZLE_IMAGE_GENERATION_POINT_COST}
onClose={() => setIsCostConfirmOpen(false)}
onConfirm={executeGeneration}
confirmDisabled={isBusy || generationProgress.isGenerating}
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)]"
/>
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
onClose={() => setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setReferenceImageSrc(asset.imageSrc);
setReferenceImageLabel(
getPuzzleHistoryAssetReferenceLabel(asset.imageSrc),
);
setAiRedraw(true);
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
}}
/>
) : null}
</PlatformToolModalShell>
);
}
@@ -860,103 +836,23 @@ function PuzzlePublishDialog({
onClose: () => void;
onPublish: () => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const primaryLevel = editState.levels[0] ?? null;
const formalImageSrc = primaryLevel
? resolveLevelFormalImageSrc(primaryLevel)
: '';
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[140] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="发布拼图作品"
className="platform-modal-shell platform-remap-surface flex max-h-[min(90vh,42rem)] w-full max-w-3xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<PlatformModalCloseButton
variant="platformIcon"
label="关闭发布拼图作品"
onClick={onClose}
/>
</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">
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
{actionError ? (
<PlatformStatusMessage tone="error" surface="platform">
{actionError}
</PlatformStatusMessage>
) : publishReady ? (
<div className="space-y-2">
<PlatformStatusMessage tone="success" surface="platform">
</PlatformStatusMessage>
<PlatformStatusMessage
tone="warning"
surface="platform"
className="font-semibold"
>
{PUZZLE_PUBLISH_POINT_COST}
</PlatformStatusMessage>
</div>
) : (
<div className="space-y-2">
{blockers.map((blocker, index) => (
<PlatformStatusMessage
key={`puzzle-publish-blocker-${index}-${blocker}`}
tone="warning"
surface="platform"
>
{blocker}
</PlatformStatusMessage>
))}
</div>
)}
</div>
<div className="space-y-3">
<PlatformFieldLabel variant="section">
</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>
</div>
</div>
</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">
return (
<PlatformToolModalShell
open
title="发布拼图作品"
onClose={onClose}
closeLabel="关闭发布拼图作品"
zIndexClassName="z-[140]"
size="lg"
panelClassName="!max-h-[min(90vh,42rem)]"
footerClassName="flex-col-reverse sm:flex-row sm:justify-end"
footer={
<>
<PlatformActionButton onClick={onClose} tone="ghost">
</PlatformActionButton>
@@ -968,10 +864,62 @@ function PuzzlePublishDialog({
? '发布中...'
: `发布到广场 · ${PUZZLE_PUBLISH_POINT_COST}泥点`}
</PlatformActionButton>
</>
}
>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_14rem]">
<div className="space-y-3">
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
{actionError ? (
<PlatformStatusMessage tone="error" surface="platform">
{actionError}
</PlatformStatusMessage>
) : publishReady ? (
<div className="space-y-2">
<PlatformStatusMessage tone="success" surface="platform">
</PlatformStatusMessage>
<PlatformStatusMessage
tone="warning"
surface="platform"
className="font-semibold"
>
{PUZZLE_PUBLISH_POINT_COST}
</PlatformStatusMessage>
</div>
) : (
<div className="space-y-2">
{blockers.map((blocker, index) => (
<PlatformStatusMessage
key={`puzzle-publish-blocker-${index}-${blocker}`}
tone="warning"
surface="platform"
>
{blocker}
</PlatformStatusMessage>
))}
</div>
)}
</div>
<div className="space-y-3">
<PlatformFieldLabel variant="section"></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>
</div>
</div>
</div>,
document.body,
</PlatformToolModalShell>
);
}