diff --git a/src/assets/images/index/popi_art_workspace_preview.png b/src/assets/images/index/popi_art_workspace_preview.png index f48c5ba..103a585 100644 Binary files a/src/assets/images/index/popi_art_workspace_preview.png and b/src/assets/images/index/popi_art_workspace_preview.png differ diff --git a/src/components/AssetAudioPlayer/index.tsx b/src/components/AssetAudioPlayer/index.tsx index 2093620..258f0a9 100644 --- a/src/components/AssetAudioPlayer/index.tsx +++ b/src/components/AssetAudioPlayer/index.tsx @@ -18,6 +18,7 @@ export interface AssetAudioPlayerProps { src: string; resetKey?: string | number; autoPlay?: boolean; + autoAdvanceOnEnded?: boolean; className?: string; onPlayingChange?: (playing: boolean) => void; canGoPrevious?: boolean; @@ -27,7 +28,7 @@ export interface AssetAudioPlayerProps { } export const AssetAudioPlayer = forwardRef(function AssetAudioPlayer( - { src, resetKey, autoPlay = false, className, onPlayingChange, canGoPrevious = false, canGoNext = false, onPrevious, onNext }, + { src, resetKey, autoPlay = false, autoAdvanceOnEnded = false, className, onPlayingChange, canGoPrevious = false, canGoNext = false, onPrevious, onNext }, forwardedRef, ) { const { t } = useTranslation(); @@ -68,7 +69,7 @@ export const AssetAudioPlayer = forwardRef { setIsPlaying(false); onPlayingChange?.(false); - if (canGoNext && onNext) { + if (autoAdvanceOnEnded && canGoNext && onNext) { onNext(); } }; @@ -97,7 +98,7 @@ export const AssetAudioPlayer = forwardRef { const audio = audioRef.current; diff --git a/src/dialog/asset-details/components/AssetExpandImageModal/index.scss b/src/dialog/asset-details/components/AssetExpandImageModal/index.scss index 42b11e7..2f355ce 100644 --- a/src/dialog/asset-details/components/AssetExpandImageModal/index.scss +++ b/src/dialog/asset-details/components/AssetExpandImageModal/index.scss @@ -214,6 +214,12 @@ color: #333333; } +.assetDetailsExpandImageModal__scaleButton:disabled { + color: #c8c8c8; + cursor: not-allowed; + opacity: 0.55; +} + .assetDetailsExpandImageModal__ratioBar { height: 66px; padding: 5px; @@ -247,6 +253,12 @@ color: #333333; } +.assetDetailsExpandImageModal__ratioButton:disabled { + color: #c8c8c8; + cursor: not-allowed; + opacity: 0.55; +} + .assetDetailsExpandImageModal__ratioIcon { width: 20px; height: 20px; diff --git a/src/dialog/asset-details/components/AssetExpandImageModal/index.tsx b/src/dialog/asset-details/components/AssetExpandImageModal/index.tsx index 75a50a2..142d2db 100644 --- a/src/dialog/asset-details/components/AssetExpandImageModal/index.tsx +++ b/src/dialog/asset-details/components/AssetExpandImageModal/index.tsx @@ -31,6 +31,9 @@ const EXPAND_IMAGE_PREVIEW_HEIGHT = 369; const EXPAND_IMAGE_ORIGINAL_IMAGE_MAX_SIZE = { width: 140, height: 122 }; const EXPAND_IMAGE_ORIGINAL_FRAME_TOP = 66; const EXPAND_IMAGE_FIXED_RATIO_MAX_SIZE = { width: 560, height: 315 }; +const EXPAND_IMAGE_MAX_DIRECTION_RATIO = 0.4; +const EXPAND_IMAGE_MAX_AXIS_RATIO = EXPAND_IMAGE_MAX_DIRECTION_RATIO * 2; +const EXPAND_IMAGE_RATIO_EPSILON = 0.0001; const EXPAND_IMAGE_SCALE_MULTIPLIERS: Record = { "1.5x": 1.5, "2x": 2, @@ -97,7 +100,7 @@ function clampNumber(value: number, min: number, max: number) { function clampExpandImageInsetRatio(value: number) { if (!Number.isFinite(value)) return 0; - return clampNumber(Math.round(value * 10000) / 10000, 0, 1); + return clampNumber(Math.round(value * 10000) / 10000, 0, EXPAND_IMAGE_MAX_DIRECTION_RATIO); } function fitExpandImageSize(aspectRatio: number, maxSize: ExpandImageSize): ExpandImageSize { @@ -117,11 +120,34 @@ function fitExpandImageSize(aspectRatio: number, maxSize: ExpandImageSize): Expa }; } -function getExpandImageAxisPositionBounds(maxOffset: number, imageSide: number) { +function getExpandImageAxisExpandRatio(maxOffset: number, imageSide: number) { if (maxOffset <= 0 || imageSide <= 0) { + return 0; + } + return maxOffset / imageSide; +} + +function isExpandImageAxisWithinLimit(maxOffset: number, imageSide: number) { + return getExpandImageAxisExpandRatio(maxOffset, imageSide) <= EXPAND_IMAGE_MAX_AXIS_RATIO + EXPAND_IMAGE_RATIO_EPSILON; +} + +function isExpandImageLayoutWithinLimit(layout: ExpandImagePreviewLayout) { + return isExpandImageAxisWithinLimit(layout.maxOffsetX, layout.imageSize.width) + && isExpandImageAxisWithinLimit(layout.maxOffsetY, layout.imageSize.height); +} + +function getExpandImageAxisPositionBounds(maxOffset: number, imageSide: number) { + const totalExpandRatio = getExpandImageAxisExpandRatio(maxOffset, imageSide); + if (totalExpandRatio <= 0) { + return { min: 0.5, max: 0.5 }; + } + if (totalExpandRatio > EXPAND_IMAGE_MAX_AXIS_RATIO + EXPAND_IMAGE_RATIO_EPSILON) { return { min: 0.5, max: 0.5 }; } - return { min: 0, max: 1 }; + + const maxPosition = Math.min(1, EXPAND_IMAGE_MAX_DIRECTION_RATIO / totalExpandRatio); + const minPosition = Math.max(0, 1 - maxPosition); + return { min: minPosition, max: maxPosition }; } function clampExpandImagePositionForLayout(position: ExpandImagePosition, layout: ExpandImagePreviewLayout): ExpandImagePosition { @@ -268,6 +294,16 @@ function getExpandImagePreviewLayout(ratio: ExpandImageRatio, scale: ExpandImage }; } +function isExpandImageScaleAvailable(scale: ExpandImageScale, imageNaturalSize: ExpandImageSize | null) { + return isExpandImageLayoutWithinLimit(getExpandImagePreviewLayout("original", scale, imageNaturalSize)); +} + +function isExpandImageRatioAvailable(ratio: ExpandImageRatio, imageNaturalSize: ExpandImageSize | null) { + if (ratio === "original") return true; + if (imageNaturalSize == null) return false; + return isExpandImageLayoutWithinLimit(getExpandImagePreviewLayout(ratio, "1.5x", imageNaturalSize)); +} + export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", onClose }: AssetExpandImageModalProps) { const { t } = useTranslation(); const setUser = useUserStore((s) => s.setUser); @@ -285,6 +321,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o const isOriginalRatio = ratio === "original"; const previewLayout = useMemo(() => getExpandImagePreviewLayout(ratio, scale, imageNaturalSize), [imageNaturalSize, ratio, scale]); + const selectedLayoutAvailable = isExpandImageLayoutWithinLimit(previewLayout); const boundedImagePosition = useMemo(() => clampExpandImagePositionForLayout(imagePosition, previewLayout), [imagePosition, previewLayout]); const positionedImageStyle = useMemo(() => ({ ...previewLayout.imageStyle, @@ -312,6 +349,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o }); const createCost = costResult.cost; const modelReady = !modelsLoading && activeModel != null && costPayload != null; + const canCreateExpandImage = modelReady && imageNaturalSize != null && selectedLayoutAvailable; const scaleHintText = isOriginalRatio ? scale : t("pages.assetDetails.expandImage.sizeHint", { @@ -327,12 +365,14 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o const handleScaleChange = (nextScale: ExpandImageScale) => { if (nextScale === scale) return; + if (!isExpandImageScaleAvailable(nextScale, imageNaturalSize)) return; resetImageDragState(); setScale(nextScale); }; const handleRatioChange = (nextRatio: ExpandImageRatio) => { if (nextRatio === ratio) return; + if (!isExpandImageRatioAvailable(nextRatio, imageNaturalSize)) return; resetImageDragState(); setRatio(nextRatio); }; @@ -379,7 +419,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o }, [visible]); const handleImagePointerDown = (event: ReactPointerEvent) => { - if (!canDragImage || creating) return; + if (!canDragImage || creating || !selectedLayoutAvailable) return; event.preventDefault(); const clampedPosition = clampExpandImagePositionForLayout(imagePosition, previewLayout); dragStateRef.current = { @@ -421,7 +461,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o }; const handleCreate = async () => { - if (createTaskInFlightRef.current || creating || !modelReady || imageNaturalSize == null) return; + if (createTaskInFlightRef.current || creating || !canCreateExpandImage) return; const payload = buildExpandImageTaskPayload({ model: activeModel, image: imageUrl, @@ -512,6 +552,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o role="radio" aria-checked={scaleOption === scale} aria-label={t("pages.assetDetails.expandImage.scaleOptionAria", { scale: scaleOption })} + disabled={!isExpandImageScaleAvailable(scaleOption, imageNaturalSize)} onClick={() => { handleScaleChange(scaleOption); }} @@ -526,6 +567,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
{EXPAND_IMAGE_RATIO_OPTIONS.map((ratioOption) => { const ratioLabel = ratioOption.value === "original" ? t("pages.assetDetails.expandImage.ratioOriginal") : ratioOption.value; + const ratioDisabled = !isExpandImageRatioAvailable(ratioOption.value, imageNaturalSize); return ( + + {maskApikey(item.apikey)} +
diff --git a/src/layouts/globalPromptPanel/ComposerMediaSortableList.tsx b/src/layouts/globalPromptPanel/ComposerMediaSortableList.tsx index 089912b..613f3f1 100644 --- a/src/layouts/globalPromptPanel/ComposerMediaSortableList.tsx +++ b/src/layouts/globalPromptPanel/ComposerMediaSortableList.tsx @@ -1,8 +1,9 @@ import { useState, type CSSProperties, type DragEvent, type PointerEvent, type ReactNode } from "react"; -import { closestCenter, DndContext, PointerSensor, useSensor, useSensors, type DragEndEvent, type UniqueIdentifier } from "@dnd-kit/core"; +import { DndContext, PointerSensor, useSensor, useSensors, type CollisionDetection, type DragEndEvent, type Modifier, type UniqueIdentifier } from "@dnd-kit/core"; import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { useHorizontalWheelScroll } from "@/hooks/useHorizontalWheelScroll"; import type { ComposerMediaItem } from "./composerSections/CommonUploadPromptSection"; export type ComposerMediaSortableListItem = { @@ -53,6 +54,30 @@ class ComposerMediaPointerSensor extends PointerSensor { ]; } +const restrictComposerMediaSortingToHorizontalAxis: Modifier = ({ transform }) => ({ + ...transform, + y: 0, +}); + +const composerMediaHorizontalCollisionDetection: CollisionDetection = ({ collisionRect, droppableRects, droppableContainers }) => { + const collisionCenterX = collisionRect.left + collisionRect.width / 2; + return droppableContainers + .map((droppableContainer) => { + const rect = droppableRects.get(droppableContainer.id); + if (!rect) return null; + const rectCenterX = rect.left + rect.width / 2; + return { + id: droppableContainer.id, + data: { + droppableContainer, + value: Math.abs(collisionCenterX - rectCenterX), + }, + }; + }) + .filter((item): item is NonNullable => item != null) + .sort((a, b) => a.data.value - b.data.value); +}; + function ComposerMediaSortableItem({ id, index, enabled, children }: ComposerMediaSortableItemProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, @@ -88,6 +113,7 @@ function ComposerMediaSortableItem({ id, index, enabled, children }: ComposerMed export function ComposerMediaSortableList({ items, enabled, className, ariaLabel, onReorderMedia, onSortStart, onSortEnd, renderItem, childrenAfterItems }: ComposerMediaSortableListProps) { const [isSorting, setIsSorting] = useState(false); + const uploadListWheelRef = useHorizontalWheelScroll({ enabled: !isSorting }); const sortableEnabled = enabled && items.length > 1 && typeof onReorderMedia === "function"; const itemIds = items.map((item) => item.id); const sensors = useSensors( @@ -119,9 +145,9 @@ export function ComposerMediaSortableList({ items, enabled, className, ariaLabel }; return ( - + -
+
{items.map((item) => ( {renderItem(item.index, { isSorting })} diff --git a/src/layouts/globalPromptPanel/composerComponents/ComposerFooter.tsx b/src/layouts/globalPromptPanel/composerComponents/ComposerFooter.tsx index b3bf156..c6e1912 100644 --- a/src/layouts/globalPromptPanel/composerComponents/ComposerFooter.tsx +++ b/src/layouts/globalPromptPanel/composerComponents/ComposerFooter.tsx @@ -6,6 +6,7 @@ import { formatRangeParamDisplay, getRangeParamBounds, getRangeParamFillPercent, import { ratioChipPreviewSize } from "../composerCommon"; import type { AiModelItem, AspectRatioItem, ComposerParamField } from "../types"; import { isRatioField, resolveVoiceRangeParamDisplayName } from "../utils"; +import { ComposerModelTags } from "./ComposerModelTags"; function getDropdownPopupContainer() { return document.body; @@ -102,7 +103,10 @@ export function ComposerFooter({ > - {item.name} + + {item.name} + + {item.description} diff --git a/src/layouts/globalPromptPanel/composerComponents/ComposerModelTags.tsx b/src/layouts/globalPromptPanel/composerComponents/ComposerModelTags.tsx new file mode 100644 index 0000000..55a54ac --- /dev/null +++ b/src/layouts/globalPromptPanel/composerComponents/ComposerModelTags.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties } from "react"; + +import type { AiModelTagItem } from "../types"; +import { normalizeAiModelTags } from "../utils"; + +const DEFAULT_MODEL_TAG_COLOR = "#fa51a2"; +const DEFAULT_MODEL_TAG_TEXT_COLOR = "#ffffff"; + +type ComposerModelTagsProps = { + tags?: readonly AiModelTagItem[] | string | null; +}; + +function getModelTagStyle(tag: AiModelTagItem): CSSProperties { + return { + backgroundColor: tag.bgColor?.trim() || DEFAULT_MODEL_TAG_COLOR, + color: tag.textColor?.trim() || DEFAULT_MODEL_TAG_TEXT_COLOR, + }; +} + +export function ComposerModelTags({ tags }: ComposerModelTagsProps) { + const visibleTags = normalizeAiModelTags(tags) ?? []; + if (visibleTags.length === 0) return null; + + return ( +
+ {visibleTags.map((tag, index) => ( +
+ {tag.label} +
+ ))} +
+ ); +} diff --git a/src/layouts/globalPromptPanel/composerSections/HeadTailUploadPromptSection.tsx b/src/layouts/globalPromptPanel/composerSections/HeadTailUploadPromptSection.tsx index 560ebd3..84bddaf 100644 --- a/src/layouts/globalPromptPanel/composerSections/HeadTailUploadPromptSection.tsx +++ b/src/layouts/globalPromptPanel/composerSections/HeadTailUploadPromptSection.tsx @@ -8,6 +8,7 @@ import { useRef, type ChangeEvent, type MutableRefObject, type MouseEvent } from "react"; import { Input } from "@arco-design/web-react"; import { useTranslation } from "react-i18next"; +import { HorizontalWheelScroll } from "@/components/HorizontalWheelScroll"; import { UploadPendingIndicator } from "@/components/UploadPendingIndicator"; import type { ComposerMediaItem } from "./CommonUploadPromptSection"; import { ComposerUploadMediaPreview } from "./ComposerUploadMediaPreview"; @@ -129,9 +130,9 @@ export function HeadTailUploadPromptSection({ renderItem={(idx, { isSorting }) => renderHeadTailSlot(idx as 0 | 1, isSorting)} /> ) : ( -
+ {headTailSlots.map((idx) => renderHeadTailSlot(idx, false))} -
+ )} diff --git a/src/layouts/globalPromptPanel/globalPromptPanel.scss b/src/layouts/globalPromptPanel/globalPromptPanel.scss index 7d7a868..43b8322 100644 --- a/src/layouts/globalPromptPanel/globalPromptPanel.scss +++ b/src/layouts/globalPromptPanel/globalPromptPanel.scss @@ -1019,21 +1019,56 @@ $composer-collapsed-gap: 8px; min-width: 0; display: flex; flex-direction: column; + flex: 1 1 auto; height: 35px; } .layoutShell__composerModelDropdownItemName { - height: 15px; + width: 100%; + min-width: 0; + height: 18px; color: #333333; font-size: 14px; font-weight: 500; - line-height: 26px; + line-height: 18px; + display: flex; + align-items: center; + gap: 5px; + overflow: hidden; + } + + .layoutShell__composerModelDropdownItemNameText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .layoutShell__composerModelDropdownTags { + display: flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + max-width: 50%; + } + + .layoutShell__composerModelDropdownTag { display: flex; align-items: center; + justify-content: center; + min-width: 0; + height: 14px; + padding: 0 5px; + border-radius: 100px; + box-sizing: border-box; + color: #ffffff; + font-size: 8px; + font-weight: 400; + line-height: 1; } .layoutShell__composerModelDropdownItemDesc { - margin-top: 5px; + margin-top: 2px; height: 15px; color: #999999; font-size: 12px; @@ -1302,4 +1337,4 @@ $composer-collapsed-gap: 8px; display: block; flex-shrink: 0; } -} +} \ No newline at end of file diff --git a/src/layouts/globalPromptPanel/globalPromptPanel.tsx b/src/layouts/globalPromptPanel/globalPromptPanel.tsx index 9ce19eb..ae04ada 100644 --- a/src/layouts/globalPromptPanel/globalPromptPanel.tsx +++ b/src/layouts/globalPromptPanel/globalPromptPanel.tsx @@ -326,7 +326,7 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false, const composerMediaUploadEnabled = supportedUploadMimePrefixes.length > 0; const taskSubTypePlaceholder = useMemo(() => { const row = getTaskSubTypeRow(activeTaskSubType); - if (!row || !("placeholder" in row)) return undefined; + if (!row || typeof row !== "object" || !("placeholder" in row)) return undefined; return t(row.placeholder); }, [activeTaskSubType, t]); diff --git a/src/layouts/globalPromptPanel/types.ts b/src/layouts/globalPromptPanel/types.ts index 927298d..22db88c 100644 --- a/src/layouts/globalPromptPanel/types.ts +++ b/src/layouts/globalPromptPanel/types.ts @@ -57,6 +57,13 @@ export type ModelDisplayDimensions = { dimensions?: Record; }; +/** 模型列表展示标签。 */ +export type AiModelTagItem = { + label: string; + bgColor?: string; + textColor?: string; +}; + /** AI 模型项(接口字段)。 */ export type AiModelItem = { id?: string | number; @@ -75,6 +82,7 @@ export type AiModelItem = { /** 音乐模型是否支持纯音乐(无歌词)创作。 */ isSupportPureMusic?: boolean; billingMethod?: number; + tags?: AiModelTagItem[] | string | null; /** 图片上传数量限制 [最小值, 最大值] 闭区间;空数组表示不限制。 */ uploadImageLimit?: number[]; /** 视频上传数量限制 [最小值, 最大值] 闭区间;空数组表示不限制。 */ diff --git a/src/layouts/globalPromptPanel/utils.ts b/src/layouts/globalPromptPanel/utils.ts index 445bc8f..d341570 100644 --- a/src/layouts/globalPromptPanel/utils.ts +++ b/src/layouts/globalPromptPanel/utils.ts @@ -5,7 +5,7 @@ import type { ComposerMediaItem, ComposerMediaType } from "./composerSections/CommonUploadPromptSection"; import { getQualityTaskParamValue, getRangeParamBounds, normalizeRangeParamValue, parseExtraTaskParams, splitRangeParamsForTaskSubmit, QUALITY_TASK_PARAM_KEY } from "./composerRangeParam"; import { buildSubjectTagName, parseReferencePromptAstFromUnknown } from "./composerSections/referencePromptAst"; -import type { AiCategoryItem, AiModelItem, AspectRatioItem, ComposerParamField, GlobalPromptDraft, GlobalPromptDraftBucket, GlobalPromptDraftStoreV2, ReferenceSubjectItem, ModelDimensionOption, ModelDisplayDimensions } from "./types"; +import type { AiCategoryItem, AiModelItem, AiModelTagItem, AspectRatioItem, ComposerParamField, GlobalPromptDraft, GlobalPromptDraftBucket, GlobalPromptDraftStoreV2, ReferenceSubjectItem, ModelDimensionOption, ModelDisplayDimensions } from "./types"; // --------------------------------------------------------------------------- // 任务与 Tab 元数据 @@ -764,11 +764,48 @@ function normalizeDraftBucket(bucket: unknown): GlobalPromptDraftBucket { */ export function normalizeAiModelItem(item: T): T { const raw = item.displayDimensions as ModelDisplayDimensions | string | undefined; + const normalizedTags = normalizeAiModelTags(item.tags); if (typeof raw === "string" && raw.trim() !== "") { - return { ...item, displayDimensions: parseDisplayDimensions(raw) }; + return { ...item, displayDimensions: parseDisplayDimensions(raw), tags: normalizedTags }; + } + if (raw != null && typeof raw === "object") return { ...item, tags: normalizedTags }; + return { ...item, displayDimensions: {}, tags: normalizedTags }; +} + +export function normalizeAiModelTags(rawTags: unknown): AiModelTagItem[] | undefined { + const parsedTags = parseAiModelTags(rawTags); + if (!Array.isArray(parsedTags)) return undefined; + const tags = parsedTags + .map((rawTag): AiModelTagItem | null => { + if (!rawTag || typeof rawTag !== "object") return null; + const row = rawTag as Record; + const label = typeof row.label === "string" ? row.label.trim() : ""; + if (!label) return null; + const bgColor = typeof row.bgColor === "string" ? row.bgColor.trim() : ""; + const textColor = typeof row.textColor === "string" ? row.textColor.trim() : ""; + return { + label, + ...(bgColor ? { bgColor } : {}), + ...(textColor ? { textColor } : {}), + }; + }) + .filter((tag): tag is AiModelTagItem => tag != null); + return tags.length > 0 ? tags : undefined; +} + +function parseAiModelTags(rawTags: unknown): unknown { + if (rawTags == null) return undefined; + if (Array.isArray(rawTags)) return rawTags; + if (typeof rawTags !== "string") return undefined; + + const value = rawTags.trim(); + if (!value) return undefined; + + try { + return JSON.parse(value) as unknown; + } catch { + return undefined; } - if (raw != null && typeof raw === "object") return item; - return { ...item, displayDimensions: {} }; } /** @@ -1173,7 +1210,7 @@ export function buildComposerTaskSubmitPayload(params: BuildComposerTaskSubmitPa return { subType: params.activeTaskSubType, ...buildGenTaskModelPayload(params.activeModelItem), - type: getTaskSubTypeRow(params.activeTaskSubType)?.type, + type: getTaskSubTypeRow(params.activeTaskSubType)?.type , ...taskParams, ...(Object.keys(extraTaskParams).length > 0 ? { extraTaskParams } : {}), }; diff --git a/src/pages/CharactersCreate/index.scss b/src/pages/CharactersCreate/index.scss index 80e56eb..547364a 100644 --- a/src/pages/CharactersCreate/index.scss +++ b/src/pages/CharactersCreate/index.scss @@ -553,17 +553,47 @@ $characters-create-skeleton-gradient: linear-gradient(90deg, rgba(0, 0, 0, 0.07) margin-top: 0; border: 0; border-radius: 26px; - background: #ffffff; - min-height: 150px; - padding: 15px 20px; - display: flex; - flex-direction: column; - justify-content: space-between; - - .charactersCreate__composerRow { - display: flex; - flex-direction: row; - flex-wrap: nowrap; + background: #ffffff; + min-height: 150px; + padding: 15px 20px; + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + transition: min-height 0.18s ease, box-shadow 0.18s ease; + + .charactersCreate__composerExpandPromptBtn { + position: absolute; + top: 10px; + right: 10px; + z-index: 5; + width: 20px; + height: 20px; + border: none; + border-radius: 5px; + background: transparent; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + img { + width: 20px; + height: 20px; + display: block; + object-fit: contain; + } + } + + .charactersCreate__composerExpandPromptBtn:hover { + background: #f0f0f0; + } + + .charactersCreate__composerRow { + display: flex; + flex-direction: row; + flex-wrap: nowrap; align-items: flex-start; gap: 10px; border-radius: 9px; @@ -975,18 +1005,35 @@ $characters-create-skeleton-gradient: linear-gradient(90deg, rgba(0, 0, 0, 0.07) } .charactersCreate__composerTextarea::placeholder { - color: #999999; - font-weight: 500; - } - - .charactersCreate__composerTextarea .arco-textarea::placeholder { - color: #999999; - font-weight: 500; - } - } - - .charactersCreate__composerToolbar { - display: flex; + color: #999999; + font-weight: 500; + } + + .charactersCreate__composerTextarea .arco-textarea::placeholder { + color: #999999; + font-weight: 500; + } + } + + &.charactersCreate__composerPanel_promptExpanded { + min-height: min(60vh, 520px); + box-shadow: 0 2px 12px rgba(51, 51, 51, 0.08); + + .charactersCreate__composerRow { + flex: 1 1 auto; + min-height: min(42vh, 360px); + padding-right: 18px; + } + + .charactersCreate__composerTextarea, + .charactersCreate__composerTextarea .arco-textarea { + min-height: min(42vh, 360px) !important; + max-height: min(42vh, 360px) !important; + } + } + + .charactersCreate__composerToolbar { + display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; diff --git a/src/pages/CharactersCreate/index.tsx b/src/pages/CharactersCreate/index.tsx index 06a7d45..068cbfd 100644 --- a/src/pages/CharactersCreate/index.tsx +++ b/src/pages/CharactersCreate/index.tsx @@ -3,8 +3,9 @@ import { Dropdown, Input, Message, Tooltip } from "@arco-design/web-react"; import { useLocation } from "react-router-dom"; import { createGenTask, createResultTask, deleteCharacterFromCreatePage, getAiCategoryList, getAiModelList, getCharacterList, getMaterialLibraryCategoryList, getMaterialLibraryList, getTaskList, getTemplateList, recordDepointEvent } from "@/api/api"; -import { openCharacterPickerDialog, openCopyrightDepositDialog, openInsufficientPointsDialog, openMaterialPickerDialog } from "@/dialog"; +import { openCharacterPickerDialog, openCopyrightDepositDialog, openInsufficientPointsDialog, openMaterialPickerDialog } from "@/dialog"; import { ratioChipPreviewSize, uploadComposerImages } from "@/layouts/globalPromptPanel/composerCommon"; +import { COMPOSER_ASSET } from "@/layouts/globalPromptPanel/composerAssets"; import { ComposerMediaSortableList } from "@/layouts/globalPromptPanel/ComposerMediaSortableList"; import { formatOriginalDiscountFoldLabel, useGenerateCost } from "@/layouts/globalPromptPanel/useGenerateCost"; import type { AiModelItem, AspectRatioItem } from "@/layouts/globalPromptPanel/types"; @@ -189,8 +190,9 @@ export default function CharactersCreate() { const [activeModelItem, setActiveModelItem] = useState(null); const [selectedParamValues, setSelectedParamValues] = useState>({}); const [modelMenuOpen, setModelMenuOpen] = useState(false); - const [paramMenuOpen, setParamMenuOpen] = useState(false); - // 用户输入:上传图、提示词。 + const [paramMenuOpen, setParamMenuOpen] = useState(false); + const [isComposerPromptExpanded, setIsComposerPromptExpanded] = useState(false); + // 用户输入:上传图、提示词。 const [images, setImages] = useState([]); const [uploadPendingCount, setUploadPendingCount] = useState(0); const uploadPendingCountRef = useRef(0); @@ -811,14 +813,18 @@ export default function CharactersCreate() { const generateCost = generateCostResult.cost; const generateDiscountFoldLabel = formatOriginalDiscountFoldLabel(generateCostResult.originalDiscount); const showGenerateDiscountBadge = generateDiscountFoldLabel != null; - const generateDiscountBadgeValue = useMemo(() => { - if (generateDiscountFoldLabel == null) return null; - const lang = i18n.resolvedLanguage ?? i18n.language; - if (lang.startsWith("zh")) return generateDiscountFoldLabel; - return String(Math.round((1 - generateCostResult.originalDiscount) * 100)); - }, [generateCostResult.originalDiscount, generateDiscountFoldLabel, i18n.language, i18n.resolvedLanguage]); - - const buildTaskParamPayload = useCallback(() => buildComposerTaskParamPayload(modelParamFields, selectedParamValues), [modelParamFields, selectedParamValues]); + const generateDiscountBadgeValue = useMemo(() => { + if (generateDiscountFoldLabel == null) return null; + const lang = i18n.resolvedLanguage ?? i18n.language; + if (lang.startsWith("zh")) return generateDiscountFoldLabel; + return String(Math.round((1 - generateCostResult.originalDiscount) * 100)); + }, [generateCostResult.originalDiscount, generateDiscountFoldLabel, i18n.language, i18n.resolvedLanguage]); + const composerPromptExpandActionLabel = isComposerPromptExpanded ? t("composer.actions.collapseInputPanel") : t("composer.actions.expandInputPanel"); + const handleComposerPromptExpandToggle = useCallback(() => { + setIsComposerPromptExpanded((expanded) => !expanded); + }, []); + + const buildTaskParamPayload = useCallback(() => buildComposerTaskParamPayload(modelParamFields, selectedParamValues), [modelParamFields, selectedParamValues]); // 点击上传卡片时,触发隐藏的 input 选择文件。 const handleUploadCardClick = useCallback(() => { @@ -1595,11 +1601,16 @@ export default function CharactersCreate() { {/* 中:创作区 */}
- {taskStageContent} - -
-
-
+ {taskStageContent} + +
+
+ + + +
+ +
diff --git a/src/pages/Voice/components/music/VoiceMusicPanel.tsx b/src/pages/Voice/components/music/VoiceMusicPanel.tsx index d1ae16c..3e2bcc0 100644 --- a/src/pages/Voice/components/music/VoiceMusicPanel.tsx +++ b/src/pages/Voice/components/music/VoiceMusicPanel.tsx @@ -8,6 +8,7 @@ import { addPostLike, delPostLike, createGenTask, deleteAsset, getAiModelList, g import { DeleteConfirmModal } from "@/components/DeleteConfirmModal"; import { HorizontalWheelScrollRow } from "@/components/HorizontalWheelScroll"; import { ensureSufficientPoints, uploadComposerMedia } from "@/layouts/globalPromptPanel/composerCommon"; +import { ComposerModelTags } from "@/layouts/globalPromptPanel/composerComponents/ComposerModelTags"; import type { AiModelItem } from "@/layouts/globalPromptPanel/types"; import { useGenerateCost } from "@/layouts/globalPromptPanel/useGenerateCost"; import { normalizeAiModelItem, buildGenTaskModelPayload } from "@/layouts/globalPromptPanel/utils"; @@ -1028,7 +1029,10 @@ function MusicComposerPanel({ onTaskCreated }: MusicComposerPanelProps) { > - {item.name} + + {item.name} + + {item.description ? {item.description} : null} diff --git a/src/pages/Voice/components/speech/VoiceSpeechPanel.tsx b/src/pages/Voice/components/speech/VoiceSpeechPanel.tsx index 7fe3313..b075e57 100644 --- a/src/pages/Voice/components/speech/VoiceSpeechPanel.tsx +++ b/src/pages/Voice/components/speech/VoiceSpeechPanel.tsx @@ -29,6 +29,7 @@ import { openAssetDetailsDialog, openVoiceLibraryPickerDialog } from "@/dialog"; import { createAssetDetailNavigation } from "@/dialog/asset-details/navigation"; import { lockLayoutContentScroll, unlockLayoutContentScroll } from "@/layouts/layoutContentModalMount"; import { ensureSufficientPoints } from "@/layouts/globalPromptPanel/composerCommon"; +import { ComposerModelTags } from "@/layouts/globalPromptPanel/composerComponents/ComposerModelTags"; import { applyRangeParamValues, formatRangeParamDisplay, @@ -538,6 +539,10 @@ function stripSpeechCursorZwsp(text: string): string { return text.replaceAll(SPEECH_CURSOR_ZWSP, ""); } +function sanitizeSpeechChatPromptForSubmit(text: string): string { + return stripSpeechCursorZwsp(text).replace(/[\s\uFEFF]+/gu, ""); +} + function isInlineAtomNode(node: ProseMirrorNode): boolean { return SPEECH_INLINE_ATOM_NODE_NAMES.includes(node.type.name as (typeof SPEECH_INLINE_ATOM_NODE_NAMES)[number]); } @@ -2927,7 +2932,7 @@ export function VoiceSpeechPanel() { const [chatPrompt, setChatPrompt] = useState(""); const [batchSize, setBatchSize] = useState(QUANTITY_MIN); - const [agreedToTerms, setAgreedToTerms] = useState(false); + const [agreedToTerms, setAgreedToTerms] = useState(true); const [sideTab, setSideTab] = useState("debug"); const [aiModels, setAiModels] = useState([]); const [activeModelItem, setActiveModelItem] = useState(null); @@ -2945,6 +2950,7 @@ export function VoiceSpeechPanel() { const [cloneModalOpen, setCloneModalOpen] = useState(false); const promptCount = chatPrompt.length; + const submittedChatPrompt = sanitizeSpeechChatPromptForSubmit(chatPrompt); const modelParamFields = useMemo(() => { const fields = getComposerParamFieldsForModel(activeModelItem); return fields.filter((field) => field.key !== "batchSize"); @@ -2977,12 +2983,12 @@ export function VoiceSpeechPanel() { return { ...composerRest, origin: VOICE_MUSIC_ORIGIN, - chatPrompt: chatPrompt.trim(), + chatPrompt: submittedChatPrompt, batchSize, ...(selectedVoiceItem?.voiceId ? { voiceId: selectedVoiceItem.voiceId } : {}), ...(Object.keys(extraTaskParams).length > 0 ? { extraTaskParams } : {}), }; - }, [activeModelItem, batchSize, chatPrompt, modelParamFields, selectedParamValues, selectedSpeechLang, selectedVoiceItem?.voiceId]); + }, [activeModelItem, batchSize, modelParamFields, selectedParamValues, selectedSpeechLang, selectedVoiceItem?.voiceId, submittedChatPrompt]); const generateCostParamValues = useMemo(() => ({ ...selectedParamValues, batchSize }), [batchSize, selectedParamValues]); @@ -2991,11 +2997,11 @@ export function VoiceSpeechPanel() { selectedParamValues: generateCostParamValues, taskPayload: createGenTaskPayload ?? undefined, activeTaskSubType: VOICE_SPEECH_SUB_TYPE, - chatPrompt, + chatPrompt: submittedChatPrompt, }); const displayGenerateCost = generateCostResult.cost; - const canGenerate = chatPrompt.trim().length > 0 && selectedVoiceItem != null && agreedToTerms && createGenTaskPayload != null; + const canGenerate = submittedChatPrompt.length > 0 && selectedVoiceItem != null && agreedToTerms && createGenTaskPayload != null; const handleOpenVoiceLibrary = useCallback(() => { void openVoiceLibraryPickerDialog({ @@ -3109,8 +3115,8 @@ export function VoiceSpeechPanel() { }; const handleGenerate = async () => { - if (!canGenerate || createGenTaskInFlightRef.current || createGenTaskPayload == null) return; - if (chatPrompt.trim() === "") { + if (createGenTaskInFlightRef.current) return; + if (submittedChatPrompt.length === 0) { Message.error(t("pages.voice.speech.promptRequired")); return; } @@ -3122,6 +3128,7 @@ export function VoiceSpeechPanel() { Message.error(t("composer.voiceLibrary.required")); return; } + if (!canGenerate || createGenTaskPayload == null) return; createGenTaskInFlightRef.current = true; setIsCreatingGenTask(true); @@ -3368,7 +3375,10 @@ export function VoiceSpeechPanel() { > - {item.name} + + {item.name} + + {item.description ? {item.description} : null} diff --git a/src/utils/horizontalWheelScroll.ts b/src/utils/horizontalWheelScroll.ts index 98d4319..35cfc45 100644 --- a/src/utils/horizontalWheelScroll.ts +++ b/src/utils/horizontalWheelScroll.ts @@ -9,9 +9,10 @@ export type HorizontalWheelScrollOptions = { * 容器需设置 `overflow-x: auto`;触控板横向手势由浏览器原生处理。 */ export function attachHorizontalWheelScroll(el: HTMLElement, options: HorizontalWheelScrollOptions = {}): () => void { - const onWheel = (event: WheelEvent) => { - const { enabled = true, ignoreWithin } = options; - if (!enabled) return; + const onWheel = (event: WheelEvent) => { + const { enabled = true, ignoreWithin } = options; + if (event.defaultPrevented) return; + if (!enabled) return; if (ignoreWithin && event.target instanceof Element && event.target.closest(ignoreWithin)) return;