feat(agc): 确认页去掉须知内层滚动改吸附页脚,发布面板继承授权改徽章

This commit is contained in:
2026-10-07 00:53:35 +08:00
parent 2010c13826
commit caf7db4ba0
3 changed files with 227 additions and 122 deletions
@@ -1107,14 +1107,17 @@ export function GameDistributionPublishPanel({
*/}
{forkSource ? (
<p
className="game-distribution-publish-panel__fork-hint"
className="game-distribution-publish-panel__fork-inherited"
aria-label="授权继承"
>
{`授权继承自父作品${
parentAuthorizationLabel
? `:${parentAuthorizationLabel}`
: '(档位读取中或不可用)'
}。衍生作品的授权档位在创建时由父作品快照继承,本页与网页端都不能再修改(服务端会拒绝任何提升)。`}
<span className="game-distribution-publish-panel__fork-inherited-badge">
{parentAuthorizationLabel
? `授权继承自父作品:${parentAuthorizationLabel}`
: '授权继承自父作品'}
</span>
<span>
衍生作品的授权档位在创建时由父作品快照继承,本页与网页端都不能再修改(服务端会拒绝任何提升)。
</span>
</p>
) : (
<div
@@ -2,16 +2,20 @@
* Fork 工程确认页(创作用户端):创作前的授权校验与工程同步环节。
*
* 结构照需求文档「Fork 工程确认页(客户端)」:父作品信息卡 → 二次确认 + 授权须知 + 风险说明
* (**强制阅读 3 秒**)→ 工程同步状态条 → 底部「取消 / 确认并进入编辑」。
* (**强制阅读 3 秒**)→ 工程同步状态条 → 底部吸附操作条「取消 / 确认并进入编辑」。
*
* 两条硬要求:
* 1. 须知**全文真实可见**(可滚动,不折叠成一句),五类内容逐条写清;
* 2. 状态条只显示**原生真实发出的阶段**(`fork.progress`),阶段内部不可测量就只做进行中指示,
* 三条硬要求:
* 1. 须知**全文真实可见**(五条逐条写清、**不折叠也不自建内层滚动**,随页面一起滚);
* 2. 强制阅读只靠**计时门禁**(底部按钮倒计时 + 顶部徽章),不要求「滚到底」;
* 3. 状态条只显示**原生真实发出的阶段**(`fork.progress`),阶段内部不可测量就只做进行中指示,
* **不造百分比**。
*
* 滚动:页面滚动容器是外壳的 `.launcher-main`(见 `styles.css` 里那条注释),本页**不自建**
* 滚动容器;底部操作条用 `position: sticky` 吸附在滚动容器下沿,因此始终可见。
*/
import { RefreshCcw } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { InitLocalProjectResult } from '../../app/types';
@@ -20,6 +24,25 @@ import {
forkNoticeSections,
MANDATORY_READING_SECONDS,
} from '../../features/platform-fork/forkNotice';
import {
FORK_BADGE_NEUTRAL,
FORK_BADGE_SUCCESS,
FORK_BADGE_WARM,
FORK_BODY_TEXT,
FORK_BUTTON_PRIMARY,
FORK_BUTTON_SECONDARY,
FORK_CARD,
FORK_META_GRID,
FORK_META_LABEL,
FORK_META_VALUE,
FORK_PAGE_COLUMN,
FORK_PAGE_ROOT,
FORK_PAGE_TITLE,
FORK_SECTION,
FORK_SECTION_HEAD,
FORK_SECTION_TITLE,
FORK_STICKY_FOOTER,
} from '../../features/platform-fork/forkPageStyles';
import {
type ForkParentCard,
forkParentCardFromGame,
@@ -44,9 +67,6 @@ type ForkConfirmViewProps = {
onCancel: () => void;
};
const sectionClass =
'mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]';
export default function ForkConfirmView({
onProjectCreated,
onCancel,
@@ -62,7 +82,6 @@ export default function ForkConfirmView({
const [remainingSeconds, setRemainingSeconds] = useState(
MANDATORY_READING_SECONDS,
);
const noticeListRef = useRef<HTMLDivElement>(null);
const gameId = target?.gameId ?? '';
const notices = useMemo(
@@ -120,13 +139,9 @@ export default function ForkConfirmView({
}, [card?.coverObjectKey]);
// 强制阅读:进入页面后倒计时 3 秒,其间确认按钮禁用(重新打开确认页会重新计时)。
// 门禁只与**时间**有关:须知随页面自然流动,不要求用户把某个内层容器滚到底。
useEffect(() => {
setRemainingSeconds(MANDATORY_READING_SECONDS);
// 须知区一进来就滚到顶部,避免上次滚动位置让用户误以为已经读过。
// 用 `scrollTop` 而不是 `scrollTo()`:jsdom 没实现后者,而这里只需要复位。
if (noticeListRef.current) {
noticeListRef.current.scrollTop = 0;
}
const startedAt = Date.now();
const timer = window.setInterval(() => {
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
@@ -146,6 +161,7 @@ export default function ForkConfirmView({
);
const reachedStages = fork.progress.reached;
const activeStage = fork.progress.current;
const syncedCount = reachedStages.length;
async function confirmFork() {
if (!gameId || readingLocked || fork.busy) {
@@ -169,13 +185,16 @@ export default function ForkConfirmView({
if (!target) {
return (
<div className="grid content-start gap-3 pb-10">
<section className={sectionClass} aria-label="Fork 确认">
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
<div className={FORK_PAGE_ROOT}>
<section
className={`${FORK_PAGE_COLUMN} ${FORK_SECTION}`}
aria-label="Fork 确认"
>
<p className={FORK_BODY_TEXT}>
没有待确认的 Fork;请回到「共创」页选择一个作品。
</p>
<button
className="w-fit cursor-pointer rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) px-3 py-1.5 text-[12px] text-(--platform-text-strong)"
className={`${FORK_BUTTON_SECONDARY} w-fit`}
type="button"
onClick={onCancel}
>
@@ -187,12 +206,10 @@ export default function ForkConfirmView({
}
return (
<div className="grid content-start gap-0 pb-10">
<header className={sectionClass}>
<h1 className="m-0 text-[19px] font-semibold text-(--platform-text-strong)">
Fork 工程确认
</h1>
<p className="m-0 mt-1 text-[12px] leading-5 text-(--platform-text-soft)">
<div className={FORK_PAGE_ROOT}>
<header className={`${FORK_PAGE_COLUMN} grid gap-1`}>
<h1 className={FORK_PAGE_TITLE}>Fork 工程确认</h1>
<p className={`${FORK_BODY_TEXT} max-w-[64ch]`}>
确认授权边界后再开始。Fork
会把整个工程复制一份到你的项目里继续改造(像 git
fork),副本完全独立。
@@ -200,10 +217,15 @@ export default function ForkConfirmView({
</header>
{/* 父作品信息卡:封面 / 标题 / 作者 / 当前授权类型(缺失逐项降级,不报错) */}
<section className={sectionClass} aria-label="父作品信息">
<div className="grid grid-cols-[112px_minmax(0,1fr)] items-stretch gap-3.5 rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-3 shadow-(--platform-panel-shadow) max-[760px]:grid-cols-[88px_minmax(0,1fr)]">
<section
className={`${FORK_PAGE_COLUMN} ${FORK_SECTION}`}
aria-label="父作品信息"
>
<div
className={`${FORK_CARD} grid grid-cols-[104px_minmax(0,1fr)] items-stretch gap-3.5 p-3 max-[760px]:grid-cols-[80px_minmax(0,1fr)]`}
>
<span
className="grid min-h-29 place-items-center overflow-hidden rounded-lg text-[26px] font-bold text-white shadow-inner"
className="grid min-h-27 place-items-center overflow-hidden rounded-lg text-[26px] font-bold text-white shadow-inner"
style={{ backgroundColor: card?.coverColor || '#c2653d' }}
>
{coverUrl ? (
@@ -218,40 +240,44 @@ export default function ForkConfirmView({
</span>
)}
</span>
<span className="grid min-w-0 content-center gap-1.5">
<small className="text-[10px] text-(--platform-text-soft)">
<span className="grid min-w-0 content-center gap-2">
<small className="text-[10px] leading-3.5 tracking-[0.08em] text-(--platform-text-soft)">
父作品
</small>
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[15px] font-semibold text-(--platform-text-strong)">
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[15px] font-semibold leading-5 text-(--platform-text-strong)">
{card?.title?.trim() ||
(cardLoading ? '正在读取作品信息…' : '(未获取到标题)')}
</strong>
<small className="grid grid-cols-[52px_minmax(0,1fr)] gap-x-2 gap-y-1 text-[11px] leading-4 text-(--platform-text-soft)">
<span className="whitespace-nowrap">作者</span>
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
<small className={`${FORK_META_GRID} text-[11px]`}>
<span className={FORK_META_LABEL}>作者</span>
<span className={FORK_META_VALUE}>
{card?.authorName?.trim() || '—'}
</span>
<span className="whitespace-nowrap">授权类型</span>
<span className="truncate whitespace-nowrap text-(--platform-warm-text)">
<span className={FORK_META_LABEL}>授权类型</span>
<span className="min-w-0 truncate whitespace-nowrap text-(--platform-warm-text)">
{card ? card.authorizationLabel : '—'}
</span>
<span className="whitespace-nowrap">作品 ID</span>
<code className="truncate whitespace-nowrap">{gameId}</code>
<span className={FORK_META_LABEL}>作品 ID</span>
<code className={FORK_META_VALUE}>{gameId}</code>
</small>
</span>
</div>
</section>
{/* 二次确认 + 授权须知 + 风险说明:全文可见(可滚动),前 3 秒确认按钮禁用 */}
<section className={sectionClass} aria-label="授权须知与风险说明">
<header className="flex items-center justify-between gap-3">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
授权须知与风险说明
</h2>
<small className="text-[11px] text-(--platform-text-soft)">
{readingLocked
? `请阅读下面的须知,${remainingSeconds} 秒后可确认`
: '已可确认'}
{/*
二次确认 + 授权须知 + 风险说明:全文随页面自然流动(**没有内层滚动**),
前 3 秒确认按钮禁用;顶部徽章给出倒计时,底部按钮同样带倒计时。
*/}
<section
className={`${FORK_PAGE_COLUMN} ${FORK_SECTION}`}
aria-label="授权须知与风险说明"
>
<header className={FORK_SECTION_HEAD}>
<h2 className={FORK_SECTION_TITLE}>授权须知与风险说明</h2>
<small
className={readingLocked ? FORK_BADGE_WARM : FORK_BADGE_SUCCESS}
>
{readingLocked ? `${remainingSeconds}s 后可确认` : '已可确认'}
</small>
</header>
<div
@@ -268,38 +294,58 @@ export default function ForkConfirmView({
/>
</div>
<div
className="grid max-h-72 gap-3 overflow-y-auto rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-3.5"
ref={noticeListRef}
className={`${FORK_CARD} grid gap-3 p-3.5`}
aria-label="授权须知全文"
>
{notices.map((notice) => (
<article className="grid gap-1" key={notice.key}>
<strong className="text-[12px] font-bold text-(--platform-text-strong)">
{notice.title}
</strong>
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
{notice.body}
</p>
{notices.map((notice, index) => (
<article
className="grid grid-cols-[22px_minmax(0,1fr)] items-start gap-2.5"
key={notice.key}
>
<span
className="mt-0.5 grid size-5 place-items-center rounded-full border border-(--platform-warm-border) bg-(--platform-warm-bg) text-[10px] font-bold text-(--platform-warm-text)"
aria-hidden="true"
>
{index + 1}
</span>
<span className="grid gap-1">
<strong className="text-[12px] font-bold leading-5 text-(--platform-text-strong)">
{notice.title}
</strong>
<p className={FORK_BODY_TEXT}>{notice.body}</p>
</span>
</article>
))}
</div>
</section>
{/* 工程同步状态条:只显示原生真实阶段,不造百分比 */}
<section className={sectionClass} aria-label="工程同步状态">
<header className="flex items-center justify-between gap-3">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
工程同步
</h2>
<small className="text-[11px] text-(--platform-text-soft)">
<section
className={`${FORK_PAGE_COLUMN} ${FORK_SECTION}`}
aria-label="工程同步状态"
>
<header className={FORK_SECTION_HEAD}>
<h2 className={FORK_SECTION_TITLE}>工程同步</h2>
<small
className={
fork.busy
? FORK_BADGE_WARM
: syncedCount > 0
? FORK_BADGE_SUCCESS
: FORK_BADGE_NEUTRAL
}
>
{fork.busy
? `正在:${activeStage ? forkProgressStageLabel(activeStage) : '准备中'}`
: reachedStages.length > 0
? '已同步完成'
: syncedCount > 0
? `已同步 ${syncedCount}/${FORK_PROGRESS_STAGES.length} 步`
: '确认后开始'}
</small>
</header>
<ol className="m-0 grid list-none gap-2 p-0" aria-label="工程同步阶段">
<ol
className="m-0 grid list-none gap-2.5 p-0"
aria-label="工程同步阶段"
>
{FORK_PROGRESS_STAGES.map((stage, index) => {
const done =
reachedStages.includes(stage.key) &&
@@ -334,66 +380,67 @@ export default function ForkConfirmView({
{stage.label}
{active ? '(进行中)' : ''}
</span>
<small className="text-[10px] text-(--platform-text-soft)">
{stage.detail}
</small>
<small className={FORK_BODY_TEXT}>{stage.detail}</small>
</span>
</li>
);
})}
</ol>
{fork.error ? (
<p className="m-0 text-[12px] leading-5 text-[#b42318]" role="alert">
<p
className="m-0 max-w-[72ch] text-[12px] leading-5 text-[#b42318]"
role="alert"
>
{fork.error}
</p>
) : null}
</section>
<section className={sectionClass}>
<div className="flex items-center justify-end gap-2.5">
<button
className="cursor-pointer rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) px-3.5 py-2 text-[12px] text-(--platform-text-strong) disabled:cursor-not-allowed disabled:opacity-55"
type="button"
disabled={fork.busy}
onClick={cancelFork}
>
取消
</button>
<button
className="cursor-pointer rounded-lg border-0 bg-(image:--platform-button-primary-fill) px-3.5 py-2 text-[12px] font-bold text-(--platform-button-primary-text) disabled:cursor-not-allowed disabled:opacity-55"
type="button"
disabled={readingLocked || fork.busy || !gameId}
onClick={() => void confirmFork()}
>
{fork.busy
? '正在 Fork…'
: readingLocked
? `确认并进入编辑(${remainingSeconds}s)`
: '确认并进入编辑'}
</button>
{/*
吸附底部操作条:`sticky bottom-0` 挂在外壳滚动容器(`.launcher-main`)下沿,
长页面滚动时始终可见;状态说明也放在条内,避免它被顶到看不见的地方。
*/}
<footer className={FORK_STICKY_FOOTER} aria-label="Fork 操作">
<div className={`${FORK_PAGE_COLUMN} grid gap-2`}>
<div className="flex flex-wrap items-center justify-end gap-2.5">
<button
className={FORK_BUTTON_SECONDARY}
type="button"
disabled={fork.busy}
onClick={cancelFork}
>
取消
</button>
<button
className={FORK_BUTTON_PRIMARY}
type="button"
disabled={readingLocked || fork.busy || !gameId}
onClick={() => void confirmFork()}
>
{fork.busy
? '正在 Fork…'
: readingLocked
? `确认并进入编辑(${remainingSeconds}s)`
: '确认并进入编辑'}
</button>
</div>
{fork.busy ? (
<p className={FORK_BODY_TEXT} role="status">
正在同步工程:内容较大时需要几分钟,请不要关闭客户端。同步期间不能取消。
</p>
) : null}
{!fork.busy && reachedStages.length > 0 ? (
<p className={FORK_BODY_TEXT} role="status">
<RefreshCcw
className="mr-1 inline-block align-[-2px]"
size={11}
aria-hidden="true"
/>
同步已完成,即将进入工作台。
</p>
) : null}
</div>
{fork.busy ? (
<p
className="m-0 mt-2 text-[11px] leading-4 text-(--platform-text-soft)"
role="status"
>
正在同步工程:内容较大时需要几分钟,请不要关闭客户端。同步期间不能取消。
</p>
) : null}
{!fork.busy && reachedStages.length > 0 ? (
<p
className="m-0 mt-2 text-[11px] leading-4 text-(--platform-text-soft)"
role="status"
>
<RefreshCcw
className="mr-1 inline-block align-[-2px]"
size={11}
aria-hidden="true"
/>
同步已完成,即将进入工作台。
</p>
) : null}
</section>
</footer>
</div>
);
}
@@ -56,10 +56,10 @@ function installInvoke(
function renderPage() {
const onProjectCreated = vi.fn();
const onCancel = vi.fn();
render(
const rendered = render(
<ForkConfirmView onCancel={onCancel} onProjectCreated={onProjectCreated} />,
);
return { onProjectCreated, onCancel };
return { ...rendered, onProjectCreated, onCancel };
}
function confirmButton() {
@@ -185,6 +185,61 @@ describe('Fork 工程确认页', () => {
expect(notices.textContent).toContain('会被平台下架并要求整改');
});
it('须知块不再自建内层滚动,页面只靠外壳滚动容器', async () => {
installInvoke(() => null);
const { container } = renderPage();
const root = container.querySelector('.fork-page');
expect(root).not.toBeNull();
// 页面内不得出现任何纵向滚动容器:须知全文随页面自然流动。
expect(container.querySelector('.fork-page .overflow-y-auto')).toBeNull();
expect(container.querySelector('.fork-page .overflow-auto')).toBeNull();
expect(container.querySelector('.fork-page .overflow-scroll')).toBeNull();
const notices = screen.getByLabelText('授权须知全文');
for (const className of Array.from(notices.classList)) {
expect(className.startsWith('max-h-')).toBe(false);
expect(className.startsWith('overflow-y-')).toBe(false);
}
// 须知仍然是**全文**:五条正文都在这一个容器里逐条渲染。
expect(notices.querySelectorAll('article')).toHaveLength(5);
});
it('底部操作条是吸附页脚:停在滚动容器下沿,不随内容滚走', async () => {
installInvoke(() => null);
const { container } = renderPage();
const footer = container.querySelector(
'[aria-label="Fork 操作"]',
) as HTMLElement;
expect(footer).not.toBeNull();
// `sticky bottom-0` 挂在页面根下(滚动容器是外壳的 `.launcher-main`)。
expect(footer.classList.contains('sticky')).toBe(true);
expect(footer.classList.contains('bottom-0')).toBe(true);
const pageRoot = container.querySelector('.fork-page') as HTMLElement;
expect(footer.parentElement).toBe(pageRoot);
expect(pageRoot.lastElementChild).toBe(footer);
expect(footer.className).toContain('border-t');
// 两个动作都在页脚里,长页面下也够得到。
expect(footer.textContent).toContain('取消');
expect(footer.textContent).toContain('确认并进入编辑');
});
it('状态是徽章形态,与按钮可区分(倒计时同时出现在徽章与按钮上)', async () => {
installInvoke(() => null);
renderPage();
const badge = screen.getByText(/^3s 后可确认$/);
// 徽章:圆角胶囊 + 描边,不是按钮。
expect(badge.tagName).toBe('SMALL');
expect(badge.className).toContain('rounded-full');
expect(badge.className).toContain('border');
expect(badge.closest('button')).toBeNull();
expect(screen.getByText('确认后开始').className).toContain('rounded-full');
finishMandatoryReading();
expect(await screen.findByText('已可确认')).not.toBeNull();
});
it('全开放授权时商用权限改写成可商用', async () => {
useForkConfirmStore.getState().close();
useForkConfirmStore.getState().open({