diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index b0ece6478..5852f9077 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -144,6 +144,10 @@ const allowedUncalledTauriCommands = [ // 同一批的 `list_platform_game_catalog` 与 `create_local_project_from_platform_game` // 都是字面量调用点,不需要登记。 'read_public_game_cover_preview', + // 父作品卡(Fork 工程确认页与发布面板):与封面同理,命令名以常量 `DETAIL_COMMAND` 传给 + // **注入的** invoke(`src/features/platform-fork/forkParentCard.ts`),静态扫描只认字面量 + // 调用点,因此显式登记为 native-only 白名单。 + 'read_public_game_detail', // 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts` // 里以字面量出现,静态扫描仍按共享注册表核验。 'read_profile_recharge_center', diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index 8fef03dba..d8deed12e 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -17,6 +17,7 @@ import { import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectExportPackageResult } from '../../app/types'; +import { loadForkParentCard } from '../../features/platform-fork/forkParentCard'; import { uploadPlatformMediaAsset } from '../../services/assetDirectUpload'; import { type GameDistributionPublicationImageDraft, @@ -37,6 +38,12 @@ import { } from '../../services/gamePackageUploadProgress'; import type { TauriEventUnsubscribe } from '../../services/tauriEventSubscription'; import { ThemedModal } from '../modal/ThemedModal'; +import { + CHANGE_SUMMARY_MAX_CHARS, + CHANGE_SUMMARY_MIN_CHARS, + describePublishFailure, + validateChangeSummary, +} from './changeSummary'; import { GamePublishPhaseSteps } from './GamePublishPhaseSteps'; /** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */ @@ -264,6 +271,16 @@ export function GameDistributionPublishPanel({ * 只是退化为「只能被产物级改编」。 */ const [projectBundleConfirmed, setProjectBundleConfirmed] = useState(true); + /** + * 本项目的改编来源记录(衍生作品才有)。 + * + * 衍生作品的差别是服务端口径:**「本次核心改动说明」必填**、**授权档位继承父作品且不可选**。 + */ + const [forkSource, setForkSource] = useState<{ gameId: string } | null>(null); + /** 父作品的授权档位文案(只读展示用);取不到就只显示「继承自父作品」。 */ + const [parentAuthorizationLabel, setParentAuthorizationLabel] = useState(''); + const [changeSummary, setChangeSummary] = useState(''); + const [changeSummaryError, setChangeSummaryError] = useState(''); /** 付费方式与买断价输入;校验口径与网页、服务端一致。 */ const [priceMode, setPriceMode] = useState('free'); const [priceInput, setPriceInput] = useState(''); @@ -493,6 +510,55 @@ export function GameDistributionPublishPanel({ } }, [manifest, open, packageResult?.packageRelativePath, projectPath]); + /** + * 衍生作品的判定与父作品档位:读项目里的 `.agent/fork-source.json`。 + * + * 服务端口径:衍生作品的「本次核心改动说明」必填、授权档位继承父作品且不可选;母版反之。 + * 记录缺失/损坏按「母版」处理(与发布链路读它的口径一致),父作品档位取不到就只显示 + * 「继承自父作品」——不猜档位。 + */ + useEffect(() => { + if (!open || !projectPath.trim()) { + setForkSource(null); + setParentAuthorizationLabel(''); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setForkSource(null); + setParentAuthorizationLabel(''); + return; + } + let cancelled = false; + void invoke<{ gameId?: string } | null>('read_local_project_fork_source', { + projectPath, + }) + .then(async (record) => { + if (cancelled) return; + const parentGameId = + typeof record?.gameId === 'string' ? record.gameId.trim() : ''; + setForkSource(parentGameId ? { gameId: parentGameId } : null); + if (!parentGameId) { + setParentAuthorizationLabel(''); + return; + } + const card = await loadForkParentCard(parentGameId); + if (!cancelled) { + setParentAuthorizationLabel(card?.authorizationLabel ?? ''); + } + }) + .catch(() => { + // 读来源记录失败不阻塞发布:按母版处理,必填校验与授权判据仍由服务端兜底。 + if (!cancelled) { + setForkSource(null); + setParentAuthorizationLabel(''); + } + }); + return () => { + cancelled = true; + }; + }, [open, projectPath]); + useEffect(() => { if (!open) return; let cancelled = false; @@ -749,6 +815,16 @@ export function GameDistributionPublishPanel({ setError('素材还在上传中,请稍候再发布'); return; } + // 衍生作品的「本次核心改动说明」必填(母版可空):本地先拦一次给可读提示, + // 服务端仍是唯一判据(超限/缺失都会带稳定错误码回来)。 + if (forkSource) { + const summaryError = validateChangeSummary(changeSummary); + if (summaryError) { + setChangeSummaryError(summaryError); + return; + } + } + setChangeSummaryError(''); const price = resolvePlatformGamePriceMudPoints(priceMode, priceInput); if (price.error) { setPriceError(price.error); @@ -824,13 +900,15 @@ export function GameDistributionPublishPanel({ ...(forkAuthorization !== 'forbidden' && !projectBundleConfirmed ? { includeProjectBundle: false } : {}), + // 衍生作品必填的「本次核心改动说明」;母版不传(服务端对母版忽略该字段)。 + ...(forkSource && changeSummary.trim() + ? { changeSummary: changeSummary.trim() } + : {}), }); setResult(next); onPublished?.(next); } catch (nextError) { - setError( - nextError instanceof Error ? nextError.message : String(nextError), - ); + setError(describePublishFailure(nextError)); } finally { unsubscribe(); setBusy(false); @@ -983,6 +1061,34 @@ export function GameDistributionPublishPanel({ ))} + {/* + 衍生作品必填的「本次核心改动说明」:版本级事实,母版不需要(服务端对母版忽略它)。 + 长度上限与服务端同值,按字符计;本地只拦「能不能提交」,判据仍在服务端。 + */} + {forkSource ? ( +