打磨封面与截图的文件选择交互

- 网页发布页:封面与截图改用透明 input 覆盖自定义胶囊按钮,点击命中原生控件;上传后不再出现「未选择任何文件」与「已选择」互相矛盾的状态。
- AGC 发布面板:同一处理,按钮文案随状态在「选择封面图片 / 更换封面图片」「添加截图 / 截图已满 6 张」之间切换。
- 文档:实施计划补充真实浏览器发布链路证据(网页上传封面 → my-games 待审 → 版本 frozenMetadata 指向本次上传素材)与交互打磨记录。
This commit is contained in:
2026-09-20 22:45:50 +08:00
parent 4a91e5865a
commit 0dde6ee501
5 changed files with 184 additions and 48 deletions
@@ -391,14 +391,24 @@ export function GameDistributionPublishPanel({
</div>
<label>
<input
ref={coverInputRef}
type="file"
accept={PANEL_IMAGE_ACCEPT}
onChange={(event) => {
void handleCoverSelected(event.target.files?.[0] ?? null);
}}
/>
{/*
原生 file 控件在清空 value 后会显示“未选择任何文件”,与下方提示互相矛盾;
上传后必须清空 value 才能重选同一张图,所以这里改用自定义触发按钮。
*/}
<span className="game-distribution-publish-panel__picker">
<input
ref={coverInputRef}
type="file"
accept={PANEL_IMAGE_ACCEPT}
disabled={busy}
onChange={(event) => {
void handleCoverSelected(event.target.files?.[0] ?? null);
}}
/>
<span className="game-distribution-publish-panel__pick">
{cover ? '更换封面图片' : '选择封面图片'}
</span>
</span>
<span className="game-distribution-publish-panel__hint">
{cover
? `已选择「${cover.name}」;重新选择即可替换封面。`
@@ -417,20 +427,27 @@ export function GameDistributionPublishPanel({
</div>
<label>
{`游戏截图(可选,最多 ${MAX_AGC_GAME_SCREENSHOTS} 张)`}
<input
ref={screenshotInputRef}
type="file"
accept={PANEL_IMAGE_ACCEPT}
multiple
disabled={
busy || screenshots.length >= MAX_AGC_GAME_SCREENSHOTS
}
onChange={(event) => {
void handleScreenshotsSelected(
Array.from(event.target.files ?? []),
);
}}
/>
<span className="game-distribution-publish-panel__picker">
<input
ref={screenshotInputRef}
type="file"
accept={PANEL_IMAGE_ACCEPT}
multiple
disabled={
busy || screenshots.length >= MAX_AGC_GAME_SCREENSHOTS
}
onChange={(event) => {
void handleScreenshotsSelected(
Array.from(event.target.files ?? []),
);
}}
/>
<span className="game-distribution-publish-panel__pick">
{screenshots.length >= MAX_AGC_GAME_SCREENSHOTS
? '截图已满 6 张'
: '添加截图'}
</span>
</span>
<span className="game-distribution-publish-panel__hint">
</span>
+53
View File
@@ -3907,6 +3907,59 @@ textarea {
font: inherit;
}
.game-distribution-publish-panel__picker {
position: relative;
display: inline-flex;
align-items: center;
}
/* 透明 input 覆盖在触发按钮上:真实点击永远命中原生控件,自定义文案只负责显示。 */
.game-distribution-publish-panel__picker input[type='file'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
opacity: 0;
cursor: pointer;
}
.game-distribution-publish-panel__pick {
display: inline-flex;
pointer-events: none;
align-items: center;
padding: 6px 14px;
border: 1px solid rgb(104 77 57 / 18%);
border-radius: 999px;
background: rgb(255 255 255 / 76%);
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: 160ms ease;
}
.game-distribution-publish-panel__picker:hover
.game-distribution-publish-panel__pick {
border-color: #a8663d;
}
.game-distribution-publish-panel__picker
input[type='file']:focus-visible
+ .game-distribution-publish-panel__pick {
outline: 2px solid rgb(168 102 61 / 24%);
outline-offset: 2px;
}
.game-distribution-publish-panel__picker
input[type='file']:disabled
+ .game-distribution-publish-panel__pick {
opacity: 0.55;
cursor: not-allowed;
}
.game-distribution-publish-panel__cover {
display: grid;
grid-template-columns: minmax(0, 168px) minmax(0, 1fr);
@@ -98,6 +98,8 @@
- 真实本地栈媒体验证(2026-09-20,`npm run dev:api-server` + `dev:web`api-server `127.0.0.1:12401`、SpacetimeDB `127.0.0.1:12402`、真实 dev OSS 桶):`E2E_ADMIN_USER=… E2E_ADMIN_PASSWORD=… npm run check:game-distribution-media-e2e``scripts/check-game-distribution-media-e2e.mjs`)27 项全部通过——真实直传封面与 2 张截图素材、缺封面 400、截图 7 张 400、不存在素材 400(创建游戏与创建版本两处)、他人素材 403、创建游戏/版本/上传/送审(202 + `pending_review`)、作者回读拿到 `frozenMetadata` 且素材 ID 与顺序一致、待审期间公开目录不含该游戏且封面匿名读返回 404、管理员审核通过后公开投影带封面与截图对象键且不含素材 ID、匿名 `read-url` 对封面与截图都返回签名地址、发行网关 `index.html``assets/app.js` 均 200 且带 nosniff。
- 真实浏览器展示验证(同一栈,headless Chromium `390x844``1280x900`):`/games` 卡片渲染真实封面签名地址(无占位回退),`/games/detail?id=…` hero 显示封面、缩略图条渲染「封面 + 2 张截图」,点击第 3 张后 `aria-pressed` 与 hero 图片同步切换;移动端布局正常。截图存于 `~/data/tmp/gd3/`(不入库)。
- 网页发布页真实上传验证(同一栈 + headless Chromium):在 `/games/publish` 里用作者会话填写资料、选择真实 PNG 封面与 ZIP 并提交,页面返回「已提交审核」;随后 `my-games` 显示该游戏 `pending_review`,版本回读的 `frozenMetadata.coverAssetId` / `coverObjectKey` 正是本次浏览器上传的素材,证明网页端封面直传(凭证 → OSS → confirm)与冻结链路真实可用。
- 发布资料入口交互打磨:原生 file 控件在上传后清空 value 时会显示「未选择任何文件」,与「已选择」提示互相矛盾;网页发布页与 AGC 面板都改成透明 input 覆盖自定义胶囊按钮(点击命中原生控件,文案显示「选择/更换封面图片」「添加截图」),并在验证中用 `elementFromPoint` 确认点击命中的是 `input[type=file]`
- 创建游戏同步校验素材归属:此前 `POST /api/game-distribution/games` 只校验「封面必填 / 截图 ≤6 / ID 非空」,素材是否存在与是否属于当前作者要等到创建版本才失败,游戏行会先落一个无效素材 ID;现在创建游戏与创建版本都走 `resolve_owned_game_media`,不存在返回 400、他人素材返回 403。
## 尚未完成
@@ -851,15 +851,26 @@ export function GamePublishPage({
<div className="game-publish-media__copy">
<label className="game-publish-field">
<input
ref={coverInputRef}
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
disabled={isResumingDraft || isSubmitting || isUploadingAsset}
onChange={(event) => {
void handleCoverSelected(event.target.files?.[0] ?? null);
}}
/>
{/*
原生 file 控件在清空 value 后会显示“未选择任何文件”,与下方的“已选择”提示互相
矛盾;上传后必须清空 value 才能重选同一张图,所以这里改用自定义触发按钮。
*/}
<span className="game-publish-media__picker">
<input
ref={coverInputRef}
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
disabled={
isResumingDraft || isSubmitting || isUploadingAsset
}
onChange={(event) => {
void handleCoverSelected(event.target.files?.[0] ?? null);
}}
/>
<span className="game-publish-media__pick">
{coverAsset ? '更换封面图片' : '选择封面图片'}
</span>
</span>
<span className="game-publish-hint">
{coverAsset?.assetObjectId
? `已选择「${coverAsset.name}」;重新选择即可替换本次发布的封面。`
@@ -883,23 +894,30 @@ export function GamePublishPage({
<div className="game-publish-media__screenshots">
<label className="game-publish-field">
{`游戏截图(可选,最多 ${MAX_GAME_SCREENSHOTS} 张)`}
<input
ref={screenshotInputRef}
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
multiple
disabled={
isResumingDraft ||
isSubmitting ||
isUploadingAsset ||
screenshotAssets.length >= MAX_GAME_SCREENSHOTS
}
onChange={(event) => {
void handleScreenshotsSelected(
Array.from(event.target.files ?? []),
);
}}
/>
<span className="game-publish-media__picker">
<input
ref={screenshotInputRef}
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
multiple
disabled={
isResumingDraft ||
isSubmitting ||
isUploadingAsset ||
screenshotAssets.length >= MAX_GAME_SCREENSHOTS
}
onChange={(event) => {
void handleScreenshotsSelected(
Array.from(event.target.files ?? []),
);
}}
/>
<span className="game-publish-media__pick">
{screenshotAssets.length >= MAX_GAME_SCREENSHOTS
? '截图已满 6 张'
: '添加截图'}
</span>
</span>
<span className="game-publish-hint">
</span>
@@ -924,6 +924,52 @@
font-size: 0.72rem;
font-weight: 600;
}
.game-publish-media__picker {
position: relative;
display: inline-flex;
align-items: center;
}
/* 透明 input 覆盖在触发按钮上:真实点击永远命中原生控件,自定义文案只负责显示。 */
.game-publish-media__picker input[type='file'] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
opacity: 0;
cursor: pointer;
}
.game-publish-media__pick {
display: inline-flex;
pointer-events: none;
align-items: center;
padding: 0.35rem 0.85rem;
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
background: var(--platform-panel-fill);
color: var(--platform-text-strong);
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
transition: 160ms ease;
}
.game-publish-media__picker:hover .game-publish-media__pick {
border-color: rgba(199, 101, 61, 0.45);
}
.game-publish-media__picker
input[type='file']:focus-visible
+ .game-publish-media__pick {
outline: 2px solid rgba(199, 101, 61, 0.35);
outline-offset: 2px;
}
.game-publish-media__picker
input[type='file']:disabled
+ .game-publish-media__pick {
opacity: 0.55;
cursor: not-allowed;
}
.game-publish-media__screenshots {
display: grid;
gap: 0.6rem;