网页侧统一改名:Fork 动作名一律叫开始共创(详情页主操作/信息面板此版本/提示文案/统计口径 + 组件测试)

This commit is contained in:
2026-10-07 13:37:58 +08:00
parent b818579a06
commit c4a1409b3a
8 changed files with 56 additions and 46 deletions
@@ -751,7 +751,7 @@ function GameDetailContent({
if (isMobileViewport) {
setIsForkPanelOpen(true);
setForkLaunchNotice(
'当前设备无法直接唤起客户端,请用下面的作品 ID 或作品链接在客户端里 Fork。',
'当前设备无法直接唤起客户端,请用下面的作品 ID 或作品链接在客户端里开始共创。',
);
return;
}
@@ -761,7 +761,7 @@ function GameDetailContent({
const handOffToClient = () => {
clearForkLaunch();
setForkLaunchNotice(
'已唤起陶泥儿客户端。若没有自动打开,请再点一次「Fork 这个作品」手动获取作品 ID。',
'已唤起陶泥儿客户端。若没有自动打开,请再点一次「开始共创」手动获取作品 ID。',
);
};
forkLaunchHandlersRef.current = handOffToClient;
@@ -982,7 +982,7 @@ function GameDetailContent({
) : null}
{publiclyVisible ? (
/*
主操作「一键 Fork 共创」(文档口径):**始终显示**。
主操作「一键开始共创」(文档口径):**始终显示**。
- 授权为「禁止共创」时置灰(aria-disabled + 视觉弱化)并在悬停/点击给出原因,
不再静默隐藏;
- 未登录按仓库既有登录门禁:入口照常出现,点击走登录弹窗;
@@ -1009,13 +1009,13 @@ function GameDetailContent({
}
title={
forkAuthorization === 'forbidden'
? '作者未开放共创授权(禁止共创),无法 Fork 这部作品'
? '作者未开放共创授权(禁止共创),无法开始共创这部作品'
: undefined
}
onClick={() => {
if (forkAuthorization === 'forbidden') {
setForkLaunchNotice(
'作者未开放共创授权(禁止共创),无法 Fork 这部作品。',
'作者未开放共创授权(禁止共创),无法开始共创这部作品。',
);
return;
}
@@ -1026,7 +1026,7 @@ function GameDetailContent({
handleForkLaunch();
}}
>
{isAuthenticated ? '一键 Fork 共创' : '登录后一键 Fork'}
{isAuthenticated ? '一键开始共创' : '登录后一键开始共创'}
</button>
{(isAuthenticated || forkAuthorization === 'forbidden') &&
forkLaunchNotice ? (
@@ -1068,8 +1068,7 @@ function GameDetailContent({
/>
</span>
<span className="game-fork-remix-note">
Fork
会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
开始共创会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
复制后对他人可见,且不可撤销。
</span>
{forkIdCopyState === 'copied' ? (
@@ -118,7 +118,7 @@ vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: copyTextToClipboardMock,
}));
/** Fork 唤起:只打桩「发出唤起」这一动作,协议常量仍用真实值(测试按同一阈值推进定时器)。 */
/** 开始共创唤起:只打桩「发出唤起」这一动作,协议常量仍用真实值(测试按同一阈值推进定时器)。 */
const openForkProtocolMock = vi.hoisted(() => vi.fn());
vi.mock('./gameForkProtocol', async (original) => ({
@@ -511,7 +511,7 @@ describe('GameDetailPage', () => {
expect(await screen.findByText('暂未发布可玩版本')).toBeTruthy();
});
it('展示共创授权、代际与溯源信息,并给出 Fork 入口', async () => {
it('展示共创授权、代际与溯源信息,并给出「一键开始共创」入口', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
forkAuthorization: 'nonCommercial',
@@ -534,8 +534,8 @@ describe('GameDetailPage', () => {
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
expect(screen.getByText('父代作者 原作者')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
// 已公开 + 授权开放 → 「一键 Fork 共创」入口出现(M1 时它还不存在,这里钉住新契约)。
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy();
// 已公开 + 授权开放 → 「一键开始共创」入口出现(M1 时它还不存在,这里钉住新契约)。
expect(screen.getByRole('button', { name: '一键开始共创' })).toBeTruthy();
});
it('旧数据缺少授权与血缘字段时只按禁止共创渲染', async () => {
@@ -1879,7 +1879,7 @@ describe('GameLineagePage', () => {
});
});
describe('GameDetailPage Fork 入口', () => {
describe('GameDetailPage 开始共创入口', () => {
it('已公开且授权开放时出现入口;唤起没被接手时降级给出可复制的作品 ID 与指引', async () => {
getGameMock.mockResolvedValueOnce(
createGame({ forkAuthorization: 'nonCommercial' }),
@@ -1888,7 +1888,7 @@ describe('GameDetailPage Fork 入口', () => {
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const entry = await screen.findByRole('button', { name: '一键 Fork 共创' });
const entry = await screen.findByRole('button', { name: '一键开始共创' });
// 未展开前不露出获取方式。
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
@@ -1912,7 +1912,7 @@ describe('GameDetailPage Fork 入口', () => {
).toBeTruthy();
expect(screen.getByText('game-1')).toBeTruthy();
expect(screen.getByText(/\/games\/detail\?id=game-1$/u)).toBeTruthy();
// 冻结口径:Fork = 复制整个工程继续改造;只保留一句话告知(对他人可见 + 不可撤销)。
// 冻结口径:开始共创 = 复制整个工程继续改造;只保留一句话告知(对他人可见 + 不可撤销)。
expect(
screen.getByText(/整个工程(含素材与文档)复制到你的项目里继续改造/u),
).toBeTruthy();
@@ -1940,7 +1940,7 @@ describe('GameDetailPage Fork 入口', () => {
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const entry = await screen.findByRole('button', { name: '一键 Fork 共创' });
const entry = await screen.findByRole('button', { name: '一键开始共创' });
vi.useFakeTimers();
fireEvent.click(entry);
expect(openForkProtocolMock).toHaveBeenCalledWith('game-1');
@@ -1970,7 +1970,7 @@ describe('GameDetailPage Fork 入口', () => {
);
fireEvent.click(
await screen.findByRole('button', { name: '一键 Fork 共创' }),
await screen.findByRole('button', { name: '一键开始共创' }),
);
expect(openForkProtocolMock).not.toHaveBeenCalled();
@@ -1988,7 +1988,7 @@ describe('GameDetailPage Fork 入口', () => {
);
const blocked = await screen.findByRole('button', {
name: '一键 Fork 共创',
name: '一键开始共创',
});
// 不静默隐藏:按钮在,但是 aria-disabled + 有原因(悬停 title)。
expect(blocked.getAttribute('aria-disabled')).toBe('true');
@@ -1997,13 +1997,13 @@ describe('GameDetailPage Fork 入口', () => {
fireEvent.click(blocked);
expect(
await screen.findByText(
/作者未开放共创授权(禁止共创),无法 Fork 这部作品。/u,
/作者未开放共创授权(禁止共创),无法开始共创这部作品。/u,
),
).toBeTruthy();
expect(openForkProtocolMock).not.toHaveBeenCalled();
});
it('未公开作品不出现 Fork 入口', async () => {
it('未公开作品不出现开始共创入口', async () => {
getMyGameMock.mockResolvedValueOnce({
...createGame({ status: 'unpublished', forkAuthorization: 'full' }),
latestVersion: null,
@@ -2019,13 +2019,13 @@ describe('GameDetailPage Fork 入口', () => {
);
await screen.findByText('作品状态');
expect(screen.queryByRole('button', { name: '一键 Fork 共创' })).toBeNull();
expect(screen.queryByRole('button', { name: '一键开始共创' })).toBeNull();
expect(
screen.queryByRole('button', { name: '登录后一键 Fork' }),
screen.queryByRole('button', { name: '登录后一键开始共创' }),
).toBeNull();
});
it('未登录时入口显示「登录后一键 Fork」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
it('未登录时入口显示「登录后一键开始共创」,点击走仓库既有登录门禁而不是静默隐藏', async () => {
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
getGameMock.mockResolvedValueOnce(
@@ -2035,7 +2035,9 @@ describe('GameDetailPage Fork 入口', () => {
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
);
const gate = await screen.findByRole('button', { name: '登录后一键 Fork' });
const gate = await screen.findByRole('button', {
name: '登录后一键开始共创',
});
expect(screen.queryByText(/从平台作品开始创作/u)).toBeNull();
fireEvent.click(gate);
@@ -3198,7 +3200,7 @@ describe('GameLineagePage 可视化与交互', () => {
expect(outlineCss).toMatch(
/\.lineage-outline__action--co-create \{[\s\S]{0,200}?--platform-button-primary-solid/u,
);
// 全树的 Fork 旧文案在两种形态里都已不存在(信息面板的「Fork 此版本」不在树节点上,另见报告)。
// 全树的 Fork 旧文案在两种形态里都已不存在(信息面板的「开始共创此版本」不在树节点上)。
expect(outline.textContent).not.toContain('Fork');
});
@@ -3473,7 +3475,7 @@ describe('GameLineagePage 可视化与交互', () => {
});
expect(screen.getByText(/没有检测到陶泥儿客户端/u)).toBeTruthy();
fireEvent.click(
screen.getByRole('button', { name: /复制 game-leaf 去 Fork/u }),
screen.getByRole('button', { name: /复制 game-leaf 去开始共创/u }),
);
await act(async () => {});
expect(copyTextToClipboardMock).toHaveBeenCalledWith('game-leaf');
@@ -3508,7 +3510,7 @@ describe('GameLineagePage 可视化与交互', () => {
expect(stubCard.getAttribute('tabindex')).toBe('0');
expect(stubCard.getAttribute('role')).toBeNull();
expect(stubCard.getAttribute('aria-disabled')).toBe('true');
// 被删作品只以「不可用」形式出现:没有标题、没有作者名、也没有 Fork/对比动作。
// 被删作品只以「不可用」形式出现:没有标题、没有作者名、也没有开始共创/对比动作。
expect(document.querySelector('.game-lineage-node--stub')).toBeTruthy();
expect(screen.queryByText(/作者 game-deleted/u)).toBeNull();
expect(within(stubCard).queryByRole('button')).toBeNull();
@@ -3694,7 +3696,7 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
).toBe(2);
});
it('选中节点后右侧信息面板给出简介/授权与「前往体验 / Fork 此版本」', async () => {
it('选中节点后右侧信息面板给出简介/授权与「前往体验 / 开始共创此版本」', async () => {
getGameLineageMock.mockResolvedValueOnce(tree);
getGameMock.mockResolvedValueOnce(
createGame({
@@ -3735,7 +3737,14 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
fireEvent.click(within(panel).getByRole('button', { name: '前往体验' }));
expect(onOpenDetail).toHaveBeenCalledWith('hot');
fireEvent.click(within(panel).getByRole('button', { name: 'Fork 此版本' }));
expect(
within(panel).getByRole('button', { name: '开始共创此版本' }),
).toBeTruthy();
// 面板内不得再把该动作叫 Fork(与按钮可见名一致)。
expect(panel.textContent).not.toContain('Fork');
fireEvent.click(
within(panel).getByRole('button', { name: '开始共创此版本' }),
);
expect(openForkProtocolMock).toHaveBeenCalledWith('hot');
});
@@ -3846,8 +3855,10 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐)
within(sheet).getByRole('button', { name: '前往体验' }),
).toBeTruthy();
expect(
within(sheet).getByRole('button', { name: 'Fork 此版本' }),
within(sheet).getByRole('button', { name: '开始共创此版本' }),
).toBeTruthy();
// 窄屏 bottom sheet 与桌面浮层同一组件:同样不得再出现 Fork 动作名。
expect(sheet.textContent).not.toContain('Fork');
// 遮罩点击关闭。
fireEvent.click(screen.getByLabelText('关闭信息面板'));
await waitFor(() =>
@@ -4210,8 +4221,8 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
expect(screen.getByText('允许非商用共创')).toBeTruthy();
expect(screen.getByText('累计衍生作品 3')).toBeTruthy();
expect(screen.getByText('2 代三创 · 改编自父代《原初之光》')).toBeTruthy();
// 主操作按文档叫「一键 Fork 共创」。
expect(screen.getByRole('button', { name: '一键 Fork 共创' })).toBeTruthy();
// 主操作按文档叫「一键开始共创」。
expect(screen.getByRole('button', { name: '一键开始共创' })).toBeTruthy();
});
it('累计创作世代数字段缺失时不渲染该概览项(不拿别的数字凑数)', async () => {
@@ -4265,7 +4276,7 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
within(card).getByRole('button', { name: '查看创作族谱' }),
).toBeTruthy();
expect(
within(card).getByRole('button', { name: '一键 Fork 共创' }),
within(card).getByRole('button', { name: '一键开始共创' }),
).toBeTruthy();
// ② 图在**正文之后**:`.game-detail-page` 的直接子级,且排在正文容器之后。
const page = document.querySelector('.game-detail-page') as HTMLElement;
@@ -88,7 +88,7 @@ type ViewportState = { scale: number; offsetX: number; offsetY: number };
/**
* 一个树节点:真实 HTML 卡片(可聚焦、可点击、有可访问名称),不是 SVG 矩形。
*
* 交互分工:主点击(含键盘 Enter/Space)进作品详情;次要动作 Fork 放在卡片内部的独立
* 交互分工:主点击(含键盘 Enter/Space)进作品详情;次要动作「开始共创」放在卡片内部的独立
* `<button>` 里并 `stopPropagation`,所以两者不会互相抢事件。
*/
function LineageTreeNodeCard({
@@ -132,7 +132,7 @@ function LineageTreeNodeCard({
const accessibleName = isStub
? `原作品已不可用 · ${generationAxisLabel(generation)}`
: `${generationAxisLabel(generation)}作品《${displayTitle}》· ${authorLabel}`;
// 父作品不可用的占位节点只能聚焦(不可点进详情,也没有 Fork 动作)。
// 父作品不可用的占位节点只能聚焦(不可点进详情,也没有「开始共创」动作)。
return (
<div
@@ -729,7 +729,7 @@ export function GameLineagePage({
dragRef.current = null;
};
/** 节点上的 Fork 次要动作:复用详情页同一条唤起 + 降级链路(协议模块 + 复制回退)。 */
/** 节点上的「开始共创」次要动作:复用详情页同一条唤起 + 降级链路(协议模块 + 复制回退)。 */
const launchForkFromNode = useCallback((gameId: string) => {
if (forkTimerRef.current !== null) {
window.clearTimeout(forkTimerRef.current);
@@ -752,7 +752,7 @@ export function GameLineagePage({
document.removeEventListener('visibilitychange', handOffToClient);
forkHandlersRef.current = null;
setForkNotice(
'已唤起陶泥儿客户端。若没有自动打开,请用作品 ID 手动 Fork。',
'已唤起陶泥儿客户端。若没有自动打开,请用作品 ID 手动开始共创。',
);
setForkFallbackGameId(gameId);
};
@@ -768,7 +768,7 @@ export function GameLineagePage({
document.removeEventListener('visibilitychange', handOffToClient);
forkHandlersRef.current = null;
setForkNotice(
'没有检测到陶泥儿客户端,已切换为手动方式:复制作品 ID 到客户端 Fork。',
'没有检测到陶泥儿客户端,已切换为手动方式:复制作品 ID 到客户端开始共创。',
);
setForkFallbackGameId(gameId);
}, FORK_LAUNCH_FALLBACK_DELAY_MS);
@@ -949,7 +949,7 @@ export function GameLineagePage({
{forkFallbackGameId ? (
<CopyFeedbackButton
state={forkCopyState}
idleLabel={`复制 ${forkFallbackGameId} 去 Fork`}
idleLabel={`复制 ${forkFallbackGameId} 去开始共创`}
copiedLabel="作品 ID 已复制"
failedLabel="复制失败"
className="game-lineage-fork-copy"
@@ -102,7 +102,7 @@ export function LineageInfoPanel({
className="game-lineage-panel__button game-lineage-panel__button--primary"
onClick={() => onOpenFork(node.gameId)}
>
Fork 此版本
开始共创此版本
</button>
</span>
</>
@@ -12,7 +12,7 @@ import type {
* 节点内容复用与画布/血缘链同一个 `LineageNodeContent`(只有一套封面色块与省略规则)。
*
* 语义与画布一致:点击行 = **选中**(高亮祖先链 + 面包屑),行内「打开」= 进详情,
* 「Fork」= 唤起/降级链路;主干链的导线加粗并用主题色。
* 「开始共创」= 唤起/降级链路;主干链的导线加粗并用主题色。
*/
export function LineageOutlineList({
nodes,
@@ -801,7 +801,7 @@ test('衍生作品的授权入口改为只读展示(授权继承父作品,
expect(screen.queryByRole('button', { name: '共创授权' })).toBeNull();
});
test('分类标签按文档口径过滤,并给出总作品数 / 累计被 Fork 次数 / 世代数', async () => {
test('分类标签按文档口径过滤,并给出总作品数 / 累计被共创次数 / 世代数', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({
id: 'g-original',
@@ -833,9 +833,9 @@ test('分类标签按文档口径过滤,并给出总作品数 / 累计被 Fork
renderPage(createAuthValue());
// 数据概览:总作品数 + 累计被 Fork 次数(世代数字段缺失 → 该项不渲染)。
// 数据概览:总作品数 + 累计被共创次数(世代数字段缺失 → 该项不渲染)。
expect(await screen.findByText('总作品数 3')).toBeTruthy();
expect(screen.getByText('累计被 Fork 次数 3')).toBeTruthy();
expect(screen.getByText('累计被共创次数 3')).toBeTruthy();
expect(screen.queryByText(/累计衍生世代数/u)).toBeNull();
// 卡片上的世代数与子衍生作品数量。
@@ -716,7 +716,7 @@ export function MyGamesPage({
<div className="co-creation-overview" aria-label="创作数据概览">
<span className="co-creation-stat">总作品数 {games.length}</span>
<span className="co-creation-stat">
累计被 Fork 次数 {totalForkCount}
累计被共创次数 {totalForkCount}
</span>
{totalDerivedGenerations === null ? null : (
<span className="co-creation-stat">
@@ -2448,7 +2448,7 @@
/*
* 移动端「大纲式缩进树」(默认形态):全宽行 + 左缩进 + 肘形导线,**无横向滚动**。
* 每行 ≥44px 触控目标;点击行=选中,行内「打开 / Fork」是次要动作。
* 每行 ≥44px 触控目标;点击行=选中,行内「打开 / 开始共创」是次要动作。
*/
.lineage-outline {
display: flex;