新增旧玩法表阶段一清空迁移能力
增加受 migration operator 保护的固定 63 张旧表清空 procedure 生成 spacetime-client procedure、输入和结果绑定 更新后端数据契约与两阶段退役决策记录 清理未挂载的旧入口、分享路由、快照持久化与旧状态死链 清理未挂载的后台旧入口、api-server 旧玩法模块与旧 prompt 清理未挂载的 SpacetimeDB 旧模块根文件与前端类型孤儿
This commit is contained in:
@@ -1,328 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminCreationEntryConfig,
|
||||
upsertAdminCreationEntryBanners,
|
||||
upsertAdminCreationEntryConfig,
|
||||
upsertAdminPublicWorkInteractions,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminCreationEntryConfigResponse,
|
||||
UnifiedCreationSpecPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminCreationEntrySwitchPage } from './AdminCreationEntrySwitchPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminCreationEntryConfig: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminCreationEntryBanners: vi.fn(),
|
||||
upsertAdminCreationEntryConfig: vi.fn(),
|
||||
upsertAdminPublicWorkInteractions: vi.fn(),
|
||||
}));
|
||||
|
||||
const puzzleSpec: UnifiedCreationSpecPayload = {
|
||||
playId: 'puzzle',
|
||||
title: '拼图',
|
||||
mudPointCost: 10,
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
resultStage: 'puzzle-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'pictureDescription',
|
||||
kind: 'text',
|
||||
label: '画面描述',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const configResponse: AdminCreationEntryConfigResponse = {
|
||||
eventBanners: [
|
||||
{
|
||||
title: '创作公告',
|
||||
description: '',
|
||||
coverImageSrc: '',
|
||||
prizePoolMudPoints: 0,
|
||||
startsAtText: '',
|
||||
endsAtText: '',
|
||||
renderMode: 'html',
|
||||
htmlCode: '<section>后台公告</section>',
|
||||
},
|
||||
],
|
||||
publicWorkInteractions: [
|
||||
{
|
||||
sourceType: 'puzzle',
|
||||
likeEnabled: true,
|
||||
remixEnabled: true,
|
||||
likeDisabledMessage: '拼图点赞暂不可用。',
|
||||
remixDisabledMessage: '拼图作品改造暂不可用。',
|
||||
},
|
||||
],
|
||||
entries: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '拼图关卡创作',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
unifiedCreationSpec: puzzleSpec,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminCreationEntryBanners).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminCreationEntryConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminPublicWorkInteractions).mockResolvedValue(
|
||||
configResponse,
|
||||
);
|
||||
});
|
||||
|
||||
test('创作入口后台展示并保存统一创作契约', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('pictureDescription');
|
||||
expect(
|
||||
container.querySelector('.admin-subsection .admin-info-list'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('.admin-subsection .admin-info-list')?.textContent,
|
||||
).toContain('拼图');
|
||||
expect(container.querySelector('.admin-panel .admin-panel')).toBeNull();
|
||||
expect(container.querySelector('.admin-muted')).toBeNull();
|
||||
expect(screen.queryByLabelText('契约 JSON')).toBeNull();
|
||||
expect(screen.queryByText('puzzle-generating')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '修改契约' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '统一创作契约' });
|
||||
expect(within(dialog).queryByLabelText('玩法 ID')).toBeNull();
|
||||
expect(within(dialog).queryByLabelText('工作台阶段')).toBeNull();
|
||||
expect(within(dialog).queryByLabelText('生成阶段')).toBeNull();
|
||||
expect(within(dialog).queryByLabelText('结果阶段')).toBeNull();
|
||||
fireEvent.change(within(dialog).getByLabelText('泥点消耗'), {
|
||||
target: { value: '12' },
|
||||
});
|
||||
await user.click(within(dialog).getByRole('button', { name: '应用修改' }));
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '统一创作契约' })).toBeNull();
|
||||
expect(screen.getByText('12泥点数')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存入库' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminCreationEntryConfig).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
id: 'puzzle',
|
||||
unifiedCreationSpec: {
|
||||
...puzzleSpec,
|
||||
mudPointCost: 12,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('创作入口后台拒绝 playId 不一致的统一创作契约', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
|
||||
...configResponse,
|
||||
entries: [
|
||||
{
|
||||
...configResponse.entries[0]!,
|
||||
unifiedCreationSpec: {
|
||||
...puzzleSpec,
|
||||
playId: 'match3d',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('pictureDescription');
|
||||
await user.click(screen.getByRole('button', { name: '保存入库' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('统一创作契约 playId 必须与入口 ID 一致'),
|
||||
).toBeTruthy();
|
||||
expect(upsertAdminCreationEntryConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('创作入口后台用表单保存公告配置', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findAllByRole('heading', { name: '创作入口公告' }),
|
||||
).toHaveLength(2);
|
||||
expect(screen.queryByLabelText('公告代码 JSON')).toBeNull();
|
||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||
target: { value: '周末创作赛' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||
target: { value: '<section>新的入口公告</section>' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '新增公告' }));
|
||||
fireEvent.change(screen.getByLabelText('公告 2 标题'), {
|
||||
target: { value: '第二条公告' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 2 HTML'), {
|
||||
target: { value: '<section>轮播第二条</section>' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存公告' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminCreationEntryBanners).toHaveBeenCalled();
|
||||
});
|
||||
const [, payload] = vi.mocked(upsertAdminCreationEntryBanners).mock.calls[0]!;
|
||||
expect(JSON.parse(payload.eventBannersJson)).toEqual([
|
||||
{
|
||||
title: '周末创作赛',
|
||||
htmlCode: '<section>新的入口公告</section>',
|
||||
},
|
||||
{
|
||||
title: '第二条公告',
|
||||
htmlCode: '<section>轮播第二条</section>',
|
||||
},
|
||||
]);
|
||||
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
|
||||
'description',
|
||||
);
|
||||
expect(JSON.parse(payload.eventBannersJson)[0]).not.toHaveProperty(
|
||||
'coverImageSrc',
|
||||
);
|
||||
});
|
||||
|
||||
test('创作入口后台用表单保存作品互动配置', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('作品互动');
|
||||
const likeToggle = screen.getAllByRole('checkbox')[0]!;
|
||||
await user.click(likeToggle);
|
||||
fireEvent.change(screen.getByLabelText('拼图 / puzzle 点赞关闭提示'), {
|
||||
target: { value: '拼图点赞维护中。' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存作品互动' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminPublicWorkInteractions).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
{
|
||||
publicWorkInteractions: [
|
||||
{
|
||||
sourceType: 'puzzle',
|
||||
likeEnabled: false,
|
||||
remixEnabled: true,
|
||||
likeDisabledMessage: '拼图点赞维护中。',
|
||||
remixDisabledMessage: '拼图作品改造暂不可用。',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('创作入口后台把旧结构化公告回显成 HTML 表单', async () => {
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValueOnce({
|
||||
...configResponse,
|
||||
eventBanners: [
|
||||
{
|
||||
title: '旧公告 <标题>',
|
||||
description: '旧描述 & 需要转义',
|
||||
coverImageSrc: '/legacy.png',
|
||||
prizePoolMudPoints: 120,
|
||||
startsAtText: '2026-06-01',
|
||||
endsAtText: '2026-06-30',
|
||||
renderMode: 'structured',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('公告 1 标题')).toHaveProperty(
|
||||
'value',
|
||||
'旧公告 <标题>',
|
||||
);
|
||||
expect(screen.getByLabelText('公告 1 HTML')).toHaveProperty(
|
||||
'value',
|
||||
'<section><h1>旧公告 <标题></h1><p>旧描述 & 需要转义</p></section>',
|
||||
);
|
||||
});
|
||||
|
||||
test('创作入口后台拒绝空公告表单', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminCreationEntrySwitchPage
|
||||
mode="announcements"
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('公告 1 标题'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('公告 1 HTML'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存公告' }));
|
||||
|
||||
expect(await screen.findByText('公告 1 标题和 HTML 都不能为空')).toBeTruthy();
|
||||
expect(upsertAdminCreationEntryBanners).not.toHaveBeenCalled();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,283 +0,0 @@
|
||||
import { Eye, EyeOff, RefreshCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
listAdminWorkVisibility,
|
||||
updateAdminWorkVisibility,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminWorkVisibilityEntryPayload } from '../api/adminApiTypes';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminWorkVisibilityPageProps {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
puzzle: '拼图',
|
||||
'puzzle-clear': '拼消消',
|
||||
'custom-world': '自定义世界',
|
||||
'jump-hop': '跳一跳',
|
||||
'wooden-fish': '敲木鱼',
|
||||
match3d: '抓大鹅',
|
||||
'square-hole': '方洞挑战',
|
||||
'visual-novel': '视觉小说',
|
||||
'big-fish': '大鱼吃小鱼',
|
||||
'bark-battle': '汪汪声浪',
|
||||
};
|
||||
|
||||
export function AdminWorkVisibilityPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminWorkVisibilityPageProps) {
|
||||
const [entries, setEntries] = useState<AdminWorkVisibilityEntryPayload[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [savingKey, setSavingKey] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
void refreshEntries();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
if (!normalizedKeyword) {
|
||||
return entries;
|
||||
}
|
||||
return entries.filter((entry) =>
|
||||
[
|
||||
entry.sourceType,
|
||||
sourceLabels[entry.sourceType] ?? '',
|
||||
entry.title,
|
||||
entry.subtitle,
|
||||
entry.authorDisplayName,
|
||||
entry.publicWorkCode,
|
||||
entry.profileId,
|
||||
entry.workId,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(normalizedKeyword),
|
||||
);
|
||||
}, [entries, keyword]);
|
||||
|
||||
async function refreshEntries() {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminWorkVisibility(token);
|
||||
setEntries(sortEntries(response.entries));
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(entry: AdminWorkVisibilityEntryPayload) {
|
||||
const nextVisible = !entry.visible;
|
||||
const target =
|
||||
entry.title.trim() || entry.publicWorkCode || entry.profileId;
|
||||
const confirmed = await confirmWrite({
|
||||
action: nextVisible ? '显示作品' : '隐藏作品',
|
||||
target,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rowKey = buildEntryKey(entry);
|
||||
setSavingKey(rowKey);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await updateAdminWorkVisibility(token, {
|
||||
sourceType: entry.sourceType,
|
||||
profileId: entry.profileId,
|
||||
visible: nextVisible,
|
||||
});
|
||||
upsertEntry(response.entry);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setSavingKey('');
|
||||
}
|
||||
}
|
||||
|
||||
function upsertEntry(next: AdminWorkVisibilityEntryPayload) {
|
||||
setEntries((current) =>
|
||||
sortEntries([
|
||||
...current.filter(
|
||||
(entry) => buildEntryKey(entry) !== buildEntryKey(next),
|
||||
),
|
||||
next,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>作品可见性</h2>
|
||||
</div>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshEntries}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="admin-panel">
|
||||
<label className="admin-field">
|
||||
<span>搜索</span>
|
||||
<input
|
||||
placeholder="标题 / 作者 / 公开码 / profileId"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>玩法</th>
|
||||
<th>作品</th>
|
||||
<th>作者</th>
|
||||
<th>公开码</th>
|
||||
<th>更新时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEntries.map((entry) => {
|
||||
const rowKey = buildEntryKey(entry);
|
||||
const isSaving = savingKey === rowKey;
|
||||
return (
|
||||
<tr key={rowKey}>
|
||||
<td>
|
||||
<span className="admin-tag">
|
||||
{sourceLabels[entry.sourceType] ?? entry.sourceType}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{entry.title || entry.profileId}</strong>
|
||||
<small>{entry.subtitle || entry.profileId}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{entry.authorDisplayName || '玩家'}
|
||||
<small>{entry.ownerUserId}</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.ownerUserId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-table-cell-ellipsis">
|
||||
{entry.publicWorkCode}
|
||||
</span>
|
||||
<small>{entry.profileId}</small>
|
||||
</td>
|
||||
<td>{formatMicros(entry.updatedAtMicros)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={
|
||||
entry.visible
|
||||
? 'admin-status admin-status-ok'
|
||||
: 'admin-status admin-status-error'
|
||||
}
|
||||
>
|
||||
{entry.visible ? '显示' : '隐藏'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className={
|
||||
entry.visible
|
||||
? 'admin-danger-button'
|
||||
: 'admin-secondary-button'
|
||||
}
|
||||
disabled={isSaving}
|
||||
type="button"
|
||||
onClick={() => handleToggle(entry)}
|
||||
>
|
||||
{entry.visible ? (
|
||||
<EyeOff size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={16} aria-hidden="true" />
|
||||
)}
|
||||
<span>
|
||||
{isSaving
|
||||
? '处理中'
|
||||
: entry.visible
|
||||
? '隐藏'
|
||||
: '显示'}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!isLoading && filteredEntries.length === 0 ? (
|
||||
<div className="admin-empty-state">暂无作品</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{confirmDialog}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function sortEntries(entries: AdminWorkVisibilityEntryPayload[]) {
|
||||
return [...entries].sort((left, right) => {
|
||||
const timeCompare = right.updatedAtMicros - left.updatedAtMicros;
|
||||
if (timeCompare !== 0) {
|
||||
return timeCompare;
|
||||
}
|
||||
const sourceCompare = left.sourceType.localeCompare(right.sourceType);
|
||||
if (sourceCompare !== 0) {
|
||||
return sourceCompare;
|
||||
}
|
||||
return left.profileId.localeCompare(right.profileId);
|
||||
});
|
||||
}
|
||||
|
||||
function buildEntryKey(entry: AdminWorkVisibilityEntryPayload) {
|
||||
return `${entry.sourceType}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
function formatMicros(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(Math.floor(value / 1000));
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
@@ -15,10 +15,5 @@
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"],
|
||||
"exclude": [
|
||||
"src/pages/AdminCreationEntrySwitchPage.tsx",
|
||||
"src/pages/AdminCreationEntrySwitchPage.test.tsx",
|
||||
"src/pages/AdminWorkVisibilityPage.tsx"
|
||||
]
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export type {
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
ImageCanvasAssetPort,
|
||||
ImageCanvasCompletionPort,
|
||||
ImageCanvasGenerationPort,
|
||||
ImageCanvasProjectPort,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
export {
|
||||
CanvasViewport as SharedCanvasViewport,
|
||||
CanvasWorld as SharedCanvasWorld,
|
||||
LayerRenderer as SharedLayerRenderer,
|
||||
Minimap as SharedMinimap,
|
||||
SelectionOverlay as SharedSelectionOverlay,
|
||||
ZoomControls as SharedZoomControls,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
@@ -1,9 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* UI 布局在父级坐标系中的轴对齐矩形。
|
||||
*
|
||||
* `min` 是矩形的最小角,`size` 是沿两个坐标轴的尺寸。这里不规定 Y 轴方向,
|
||||
* 因而既能用于 Y 轴向上的游戏坐标,也能用于 Y 轴向下的画布坐标。
|
||||
*/
|
||||
export type UIRect = { min: [number, number], size: [number, number], };
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { UiEditorPrerequisiteIssue } from '../../../features/ui-editor/requisites';
|
||||
|
||||
export function PrerequisiteIssues({
|
||||
issues,
|
||||
}: {
|
||||
issues: UiEditorPrerequisiteIssue[];
|
||||
}) {
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-xs text-emerald-800">
|
||||
当前 State 已满足该工具的前置数据要求。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900">
|
||||
<strong>还不能执行此操作</strong>
|
||||
<ul className="mt-2 space-y-1 pl-4">
|
||||
{issues.map((issue, index) => (
|
||||
<li key={`${issue.code}-${issue.resourceId ?? 'none'}-${index}`}>
|
||||
{issue.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,16 @@
|
||||
- 验证方式:运行评论弹层恢复竞态回归、完整 `appSurface.test.ts`,并执行类型、编码和 diff 检查。
|
||||
- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`、`apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx`。
|
||||
|
||||
## 2026-09-02 旧玩法表采用两阶段退役清理
|
||||
|
||||
- 背景:旧创作模板的业务代码已退出现役编译链,但 SpacetimeDB 中的历史表仍需先完成数据清理;直接删除表定义会扩大 schema 迁移和客户端兼容风险。
|
||||
- 决策:阶段一只在 `spacetime-module/src/migration.rs` 增加受 `database_migration_operator` 保护的 `clear_retired_database_tables` procedure。procedure 使用固定的 63 张旧玩法表清单,不接受动态表名;`dry_run=true` 只返回逐表行数统计,`dry_run=false` 在同一事务内逐表清空,任一失败整体回滚。阶段一不删除表定义、不修改 `legacy_schema/**`、migration 导入导出白名单或生成 bindings。
|
||||
- 阶段边界:清理清单包含旧 gameplay、`custom_world`、Puzzle / Puzzle Clear、Bark Battle、Match3D、Jump Hop、Wooden Fish、Square Hole、Visual Novel 和 Big Fish 表;`runtime_setting`、`runtime_snapshot`、`user_browse_history`、`creation_entry_config` 等现役表明确排除。阶段二只有在备份、客户端兼容性和运行态确认完成后,才评估从 module 定义与 migration 白名单移除空表,并按 schema / bindings 流程发布;固定清单旁保留 TODO。
|
||||
- 影响范围:SpacetimeDB migration procedure、`spacetime-client` 生成 bindings、后端数据契约和本决策记录;禁止新增 SQL `DROP TABLE`、`--delete-data=always` 或直接写系统表的实现。
|
||||
- 验证方式:固定清单测试确认数量为 63 且不含现役表;本地数据库以已授权 operator 执行 dry-run,确认 63 张表均返回 0 行且未写入;apply 的单事务回滚由 procedure 实现,实际 apply 仅在另行授权的维护窗口执行。另运行 bindings 生成、SpacetimeDB schema / runtime 检查、编码和 diff 门禁。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`server-rs/crates/spacetime-module/src/migration.rs`。
|
||||
- 补充落地:阶段一同时删除此前仅因退役而保留的旧玩法业务实现、未挂载 API handler/router/worker、旧客户端 facade/mapper、旧领域 crate 和 `retired/legacy-creation-templates/**` 归档;`legacy_schema/**`、生成表 bindings 和当前前端仍引用的公共契约不在本次删除范围。
|
||||
|
||||
## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入
|
||||
|
||||
- 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。
|
||||
@@ -6558,7 +6568,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 并发补验:确定性 Provider 只在 Runtime 明确返回 revision blocker、专业 verification-only repair、成功验证 observation,或项目锁 / repository context drift 这两类可恢复 observation 时重放终态;每个 logical run 最多 16 次。只读职责不得借补验调用未授权命令,验证失败或缺少 `ok` observation 不能交付,Provider completion 计数始终 exactly-once。
|
||||
- 画布审计:资源 manifest 可以保留生成 prompt 作为本地来源元数据,但公开 `asset.register / asset.update` 审计记录必须移除 `source.prompt`,只保留 canvas/resource/task/model 等身份字段,避免完整生成正文进入公开 Agent DB 表面。
|
||||
- 当前测试事实:已有回归覆盖固定画布合同不允许被模型改写、已登记 spritesheet 禁止先删除、只有静态 repair 可原位替换、替换期间原文件 fingerprint 漂移时拒绝覆盖,以及 `design-foundation` 对 `game/index.html` 的 write / patchset / delete 和预览工具均被 Runtime 策略阻断。2026-07-27 的独立 75 分钟上限外部真实 E2E 已按上一节单轮证据完整 **PASS**;后续合同变化仍须新起独立轮次,不能复用这次结果替代未来验收。
|
||||
- 最终落地:本次退役范围覆盖整个旧创作模板体系,包括 RPG / 自定义世界、拼图、拼消消、大鱼吃小鱼、敲木鱼、方洞挑战、视觉小说、汪汪声浪、寓教于乐、Creative Agent、Match3D、跳一跳和儿童动作 Demo。全部相关历史表继续作为数据壳参与 `spacetime-module` 编译,`migration.rs` 白名单与历史数据不变;旧 reducer/procedure/view、API 路由/handler/worker、前端页面/工作台/运行态、共享业务 DTO 和纯业务 crate 从编译链与依赖图移除,但旧源码和素材保留在仓库中用于历史追溯。
|
||||
- 最终落地:本次退役范围覆盖整个旧创作模板体系,包括 RPG / 自定义世界、拼图、拼消消、大鱼吃小鱼、敲木鱼、方洞挑战、视觉小说、汪汪声浪、寓教于乐、Creative Agent、Match3D、跳一跳和儿童动作 Demo。全部相关历史表继续作为数据壳参与 `spacetime-module` 编译,`migration.rs` 白名单与历史数据不变;旧 reducer/procedure/view、API 路由/handler/worker、前端页面/工作台/运行态、共享业务 DTO、纯业务 crate、未挂载旧实现和归档源码均删除,现役公共能力与历史表 schema 保留。
|
||||
- 兼容读取:只保留历史审计、迁移和资产归属核对所需的最小读取定义;旧 `worldType`、公开作品号、URL、详情页和专属运行态均不再形成用户可访问入口。
|
||||
- 方案文档:`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。
|
||||
|
||||
@@ -6573,7 +6583,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- Rust 产物边界:`module-runtime` 继续承载账号、钱包、公共设置、追踪和 feature gate,但 `CreationEntry*`、旧公开作品、存档、浏览历史与游玩统计 DTO / command / mapper / 规则必须退出实际 rlib;只保留历史表需要的 `RuntimeBrowseHistoryThemeMode`、完整保序的钱包流水来源枚举等持久化 ABI。`check:server-rs-ddd` 必须执行 `check:module-runtime-artifact`,同时验证旧符号和字面量为零、必要 ABI 仍存在,不能以源码存在 `#[cfg(any())]` 或路由未挂载代替产物证明。
|
||||
- 外围编译边界:`platform-auth` 不再编译 runtime guest token,`platform-wechat` 不再编译旧玩法生成结果订阅消息,小程序不再注册订阅授权页;旧公开作品资产授权 view 退出 SpacetimeDB module,匿名素材读取只保留现役 editor showcase 派生授权。
|
||||
- 历史队列边界:现役 external generation worker 只领取 `source_module = editor-canvas` 的任务,历史旧玩法 pending / running 行保持原状态,不得被新 worker 领取后改写为失败。
|
||||
- Agent crate 边界:`platform-agent` 的执行器、工具注册表、回调和拼图 Phase 1 输入均属于已退役 Creative Agent 业务,不得因现役编辑器 Agent 共用一个模型名常量而留在 workspace 或 `api-server` 依赖图。该常量收口到 `platform-llm`,`platform-agent` 与仅由它引入的 `langchainrust` 退出在运 Cargo resolve graph,源码目录继续仅作历史追溯。
|
||||
- Agent crate 边界:`platform-agent` 的执行器、工具注册表、回调和拼图 Phase 1 输入均属于已退役 Creative Agent 业务,不得因现役编辑器 Agent 共用一个模型名常量而留在 workspace 或 `api-server` 依赖图。该常量收口到 `platform-llm`,`platform-agent` 与仅由它引入的 `langchainrust` 退出在运 Cargo resolve graph,相关源码已删除。
|
||||
- AI 游戏创作兼容边界:独立 AGC Tauri 壳仍复用 `platform-agent::game_creation` 的任务图与隔离协作数据模型。`platform-agent` 继续排除在 `server-rs` workspace 之外,但其独立 manifest 默认只编译 `game_creation` / `error`,旧执行器、工具注册表、回调、拼图 Phase 1 与 `langchainrust` 统一受关闭的 `legacy-creative-agent` feature 隔离;AGC lock 不得重新引入这些退役依赖。
|
||||
- 防回流补充:顶层 `creationEntryConfigService`、`creationUrlState`、`customWorld*`、`runtimeGuestAuth`、`runtimeRequest`、`input-devices`、`useCombatFlow`、`useStoryOptions`、`useMocapInput` 和微信生成订阅 facade 同样属于退役前端模块;Vite dev 对旧 `/api/creation*` 与 `/api/public-works*` 前缀直接返回 404,不能回落 SPA HTML。
|
||||
- Vite 全量边界补充:`src/games/**`、`src/data/**`、`src/prompts/**`、旧顶层 App / Playground、旧路由和 `services/ai.ts` 必须由 pre-transform 门禁直接拒绝;所有同源 `/generated-*` 裸读在 dev 与生产统一为空 `404`,历史对象只经现役签名读取接口兼容,不允许 SPA fallback 伪装成资产成功响应。
|
||||
|
||||
@@ -70,7 +70,7 @@ src/
|
||||
|
||||
根级原生壳检查同时锁定 HostBridge 模块分类:`dispatch` / `protocol` 是微信、移动、桌面三端共同模块;`appearance`、`badge`、`capabilities`、`clipboard`、`file-payloads`、`files`、`navigation`、`network`、`notifications`、`runtime`、`share` 是 Expo / Tauri 原生 App 壳共同模块;移动端专属 `bridge`、`haptics`、`scanner`,桌面端专属 `mod`、`title`,微信端专属 `payment`、`shareGrid`、`webView`。后续新增或拆分桥接模块不能只改文件清单,必须先说明它属于三端共同、原生 App 共同还是某端专属。
|
||||
|
||||
2026-07-18 调整:旧玩法生成结果订阅授权已退役,微信 capability profile 不再声明 `navigation.openNativePage`,`subscribeMessage` host-bridge / shell / page 历史源码移入 `retired/legacy-creation-templates/frontend/miniprogram/`,不再进入小程序包和 `check:native-shells` 现役文件清单。
|
||||
2026-07-18 调整:旧玩法生成结果订阅授权已退役,微信 capability profile 不再声明 `navigation.openNativePage`,`subscribeMessage` host-bridge / shell / page 已从仓库删除,不再进入小程序包和 `check:native-shells` 现役文件清单。
|
||||
|
||||
生产替身词扫描只覆盖上述壳源码、分发配置、共享 HostBridge 契约和已接入真实宿主能力的 H5 调用链;Expo export、Tauri `target/`、Cargo / Metro 缓存和 release 构建产物不进入扫描范围,避免本地或 CI 生成文件污染源码门禁。
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
-129
@@ -1,129 +0,0 @@
|
||||
/* global wx */
|
||||
|
||||
const SUBSCRIBE_RESULT_STORAGE_KEY = 'genarrative:wechat-subscribe-result';
|
||||
const WECHAT_SUBSCRIBE_UNAVAILABLE_REASON = 'wechat subscribe unavailable';
|
||||
|
||||
function logWechatSubscribeFailure(label, _error) {
|
||||
console.error(`[subscribe-message] ${label}`);
|
||||
}
|
||||
|
||||
function appendSubscribeResult(url, result) {
|
||||
const hashIndex = String(url || '').indexOf('#');
|
||||
const baseUrl =
|
||||
hashIndex >= 0 ? String(url).slice(0, hashIndex) : String(url || '');
|
||||
const rawHash = hashIndex >= 0 ? String(url).slice(hashIndex + 1) : '';
|
||||
const nextHash = rawHash
|
||||
.split('&')
|
||||
.filter((part) => part && !part.startsWith('wx_subscribe_result='))
|
||||
.concat(`wx_subscribe_result=${encodeURIComponent(result)}`)
|
||||
.join('&');
|
||||
return `${baseUrl}#${nextHash}`;
|
||||
}
|
||||
|
||||
function buildSubscribeResultValue(requestId, status, reason) {
|
||||
const segments = [requestId, status];
|
||||
if (reason) {
|
||||
segments.push(encodeURIComponent(reason));
|
||||
}
|
||||
return segments.join(':');
|
||||
}
|
||||
|
||||
function notifyPreviousWebView(requestId, status, reason) {
|
||||
const result = buildSubscribeResultValue(requestId, status, reason);
|
||||
wx.setStorageSync(SUBSCRIBE_RESULT_STORAGE_KEY, result);
|
||||
}
|
||||
|
||||
function resolveSubscribeStatus(result, templateId) {
|
||||
return result && result[templateId] === 'accept' ? 'success' : 'skip';
|
||||
}
|
||||
|
||||
function createSubscribeMessagePageController(pageContext, options = {}) {
|
||||
const templateId = String(options.templateId || '').trim();
|
||||
const notifyPageResult = (methodThis, status, reason) => {
|
||||
const page = pageContext ?? methodThis;
|
||||
const requestId = page.requestId || '';
|
||||
if (!requestId || page.hasNotifiedSubscribeResult) {
|
||||
return;
|
||||
}
|
||||
page.hasNotifiedSubscribeResult = true;
|
||||
notifyPreviousWebView(requestId, status, reason);
|
||||
};
|
||||
|
||||
return {
|
||||
data: {
|
||||
title: '接收生成结果通知',
|
||||
errorMessage: '',
|
||||
requesting: false,
|
||||
},
|
||||
|
||||
onLoad(query) {
|
||||
const page = pageContext ?? this;
|
||||
page.requestId = String(query.requestId || '');
|
||||
page.hasNotifiedSubscribeResult = false;
|
||||
},
|
||||
|
||||
notifyResult(status, reason) {
|
||||
notifyPageResult(this, status, reason);
|
||||
},
|
||||
|
||||
requestSubscribe() {
|
||||
const page = pageContext ?? this;
|
||||
const requestId = page.requestId || '';
|
||||
if (!requestId) {
|
||||
page.setData({
|
||||
errorMessage: '缺少订阅请求参数。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!templateId) {
|
||||
notifyPageResult(this, 'skip', 'missing_template_id');
|
||||
wx.navigateBack();
|
||||
return;
|
||||
}
|
||||
if (typeof wx.requestSubscribeMessage !== 'function') {
|
||||
notifyPageResult(this, 'skip', 'unsupported');
|
||||
wx.navigateBack();
|
||||
return;
|
||||
}
|
||||
|
||||
page.setData({
|
||||
requesting: true,
|
||||
errorMessage: '',
|
||||
});
|
||||
wx.requestSubscribeMessage({
|
||||
tmplIds: [templateId],
|
||||
success(result) {
|
||||
notifyPageResult(
|
||||
page,
|
||||
resolveSubscribeStatus(result, templateId),
|
||||
'',
|
||||
);
|
||||
wx.navigateBack();
|
||||
},
|
||||
fail(error) {
|
||||
logWechatSubscribeFailure('request failed', error);
|
||||
notifyPageResult(page, 'skip', WECHAT_SUBSCRIBE_UNAVAILABLE_REASON);
|
||||
wx.navigateBack();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
handleSkip() {
|
||||
notifyPageResult(this, 'skip', 'user_skip');
|
||||
wx.navigateBack();
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
notifyPageResult(this, 'skip', 'page_unload');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||
WECHAT_SUBSCRIBE_UNAVAILABLE_REASON,
|
||||
appendSubscribeResult,
|
||||
buildSubscribeResultValue,
|
||||
createSubscribeMessagePageController,
|
||||
resolveSubscribeStatus,
|
||||
};
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
|
||||
|
||||
const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
|
||||
|
||||
const subscribeBridgePath = path.resolve(
|
||||
process.cwd(),
|
||||
'miniprogram/host-bridge/subscribeMessage.js',
|
||||
);
|
||||
|
||||
describe('subscribe-message mini program bridge', () => {
|
||||
let subscribeMessageBridge;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
globalThis.wx = {
|
||||
requestSubscribeMessage: vi.fn(),
|
||||
setStorageSync: vi.fn(),
|
||||
navigateBack: vi.fn(),
|
||||
};
|
||||
globalThis.getCurrentPages = vi.fn(() => []);
|
||||
subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath);
|
||||
});
|
||||
|
||||
test('requests subscribe message and stores result before returning', () => {
|
||||
const {
|
||||
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||
createSubscribeMessagePageController,
|
||||
} = subscribeMessageBridge;
|
||||
const previousPage = {
|
||||
data: { webViewUrl: 'https://web.test/#tab=create' },
|
||||
setData: vi.fn(),
|
||||
};
|
||||
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
|
||||
globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => {
|
||||
options.success?.({
|
||||
m5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU: 'accept',
|
||||
});
|
||||
});
|
||||
const page = createSubscribeMessagePageController(
|
||||
{
|
||||
setData: vi.fn(),
|
||||
},
|
||||
{ templateId: TEST_TEMPLATE_ID },
|
||||
);
|
||||
page.onLoad({ requestId: 'request-1' });
|
||||
|
||||
page.requestSubscribe();
|
||||
|
||||
expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({
|
||||
tmplIds: [TEST_TEMPLATE_ID],
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
|
||||
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||
'request-1:success',
|
||||
);
|
||||
expect(previousPage.setData).not.toHaveBeenCalled();
|
||||
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('hides requestSubscribeMessage native failure details from H5 result', () => {
|
||||
const {
|
||||
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||
createSubscribeMessagePageController,
|
||||
} = subscribeMessageBridge;
|
||||
const subscribeError = {
|
||||
errMsg: 'requestSubscribeMessage:fail private native detail',
|
||||
};
|
||||
globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => {
|
||||
options.fail?.(subscribeError);
|
||||
});
|
||||
const page = createSubscribeMessagePageController(
|
||||
{
|
||||
setData: vi.fn(),
|
||||
},
|
||||
{ templateId: TEST_TEMPLATE_ID },
|
||||
);
|
||||
page.onLoad({ requestId: 'request-fail' });
|
||||
|
||||
page.requestSubscribe();
|
||||
|
||||
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
|
||||
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||
'request-fail:skip:wechat%20subscribe%20unavailable',
|
||||
);
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'[subscribe-message] request failed',
|
||||
);
|
||||
expect(console.error.mock.calls.flat()).not.toContain(subscribeError);
|
||||
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skip action notifies previous web-view', () => {
|
||||
const {
|
||||
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||
createSubscribeMessagePageController,
|
||||
} = subscribeMessageBridge;
|
||||
const previousPage = {
|
||||
data: { webViewUrl: 'https://web.test/' },
|
||||
setData: vi.fn(),
|
||||
};
|
||||
globalThis.getCurrentPages = vi.fn(() => [previousPage, {}]);
|
||||
const page = createSubscribeMessagePageController(
|
||||
{
|
||||
setData: vi.fn(),
|
||||
},
|
||||
{ templateId: TEST_TEMPLATE_ID },
|
||||
);
|
||||
page.onLoad({ requestId: 'request-skip' });
|
||||
|
||||
page.handleSkip();
|
||||
|
||||
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
|
||||
SUBSCRIBE_RESULT_STORAGE_KEY,
|
||||
'request-skip:skip:user_skip',
|
||||
);
|
||||
expect(previousPage.setData).not.toHaveBeenCalled();
|
||||
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('appendSubscribeResult replaces stale subscribe hash', () => {
|
||||
const { appendSubscribeResult, buildSubscribeResultValue } =
|
||||
subscribeMessageBridge;
|
||||
expect(
|
||||
appendSubscribeResult(
|
||||
'https://web.test/#old=1&wx_subscribe_result=old',
|
||||
'req:skip',
|
||||
),
|
||||
).toBe('https://web.test/#old=1&wx_subscribe_result=req%3Askip');
|
||||
expect(buildSubscribeResultValue('req-1', 'skip', 'user_cancel')).toBe(
|
||||
'req-1:skip:user_cancel',
|
||||
);
|
||||
});
|
||||
});
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
/* global Page */
|
||||
|
||||
const { GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID } = require('../../config');
|
||||
const { createSubscribeMessagePage } = require('../../shell/subscribeMessage');
|
||||
|
||||
Page(
|
||||
createSubscribeMessagePage(null, {
|
||||
templateId: GENERATION_RESULT_SUBSCRIBE_TEMPLATE_ID,
|
||||
}),
|
||||
);
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"navigationBarTitleText": "生成通知"
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<view class="subscribe-screen">
|
||||
<view class="subscribe-card">
|
||||
<view class="subscribe-title">{{title}}</view>
|
||||
<view wx:if="{{errorMessage}}" class="subscribe-text subscribe-text--danger">
|
||||
{{errorMessage}}
|
||||
</view>
|
||||
<button
|
||||
class="primary-button"
|
||||
loading="{{requesting}}"
|
||||
disabled="{{requesting}}"
|
||||
bindtap="requestSubscribe"
|
||||
>
|
||||
继续并接收通知
|
||||
</button>
|
||||
<button class="ghost-button" disabled="{{requesting}}" bindtap="handleSkip">
|
||||
仅继续生成
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
.subscribe-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48rpx;
|
||||
background: #0b0f14;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.subscribe-card {
|
||||
width: 100%;
|
||||
max-width: 560rpx;
|
||||
padding: 36rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 12rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.subscribe-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: #f5f7fb;
|
||||
}
|
||||
|
||||
.subscribe-text {
|
||||
margin-top: 16rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.55;
|
||||
color: rgba(245, 247, 251, 0.72);
|
||||
}
|
||||
|
||||
.subscribe-text--danger {
|
||||
color: #ffb4a9;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.ghost-button {
|
||||
margin-top: 28rpx;
|
||||
width: 100%;
|
||||
border-radius: 8rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 2.6;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: #f5f7fb;
|
||||
color: #0b0f14;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
margin-top: 20rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.24);
|
||||
background: transparent;
|
||||
color: rgba(245, 247, 251, 0.86);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
const {
|
||||
createSubscribeMessagePageController,
|
||||
} = require('../host-bridge/subscribeMessage');
|
||||
|
||||
function createSubscribeMessagePage(pageContext, options = {}) {
|
||||
return createSubscribeMessagePageController(pageContext, options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSubscribeMessagePage,
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
|
||||
|
||||
const TEST_TEMPLATE_ID = 'm5z7BkkBhJGbcH0cdDeHaeRU2tViDEguP38XdrRRCdU';
|
||||
|
||||
const subscribeBridgePath = path.resolve(
|
||||
process.cwd(),
|
||||
'miniprogram/host-bridge/subscribeMessage.js',
|
||||
);
|
||||
const subscribeShellPath = path.resolve(
|
||||
process.cwd(),
|
||||
'miniprogram/shell/subscribeMessage.js',
|
||||
);
|
||||
|
||||
describe('wechat subscribe-message shell page', () => {
|
||||
let createSubscribeMessagePage;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.wx = {
|
||||
navigateBack: vi.fn(),
|
||||
requestSubscribeMessage: vi.fn(),
|
||||
setStorageSync: vi.fn(),
|
||||
};
|
||||
const subscribeMessageBridge = loadCommonJsModule(subscribeBridgePath);
|
||||
const subscribeMessageShell = loadCommonJsModule(subscribeShellPath, {
|
||||
'../host-bridge/subscribeMessage': subscribeMessageBridge,
|
||||
});
|
||||
createSubscribeMessagePage =
|
||||
subscribeMessageShell.createSubscribeMessagePage;
|
||||
});
|
||||
|
||||
test('requests generation-result subscribe template and stores result', () => {
|
||||
globalThis.wx.requestSubscribeMessage.mockImplementationOnce((options) => {
|
||||
options.success?.({
|
||||
[TEST_TEMPLATE_ID]: 'accept',
|
||||
});
|
||||
});
|
||||
const page = createSubscribeMessagePage(
|
||||
{ setData: vi.fn() },
|
||||
{ templateId: TEST_TEMPLATE_ID },
|
||||
);
|
||||
page.onLoad({ requestId: 'request-1' });
|
||||
|
||||
page.requestSubscribe();
|
||||
|
||||
expect(globalThis.wx.requestSubscribeMessage).toHaveBeenCalledWith({
|
||||
tmplIds: [TEST_TEMPLATE_ID],
|
||||
success: expect.any(Function),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
expect(globalThis.wx.setStorageSync).toHaveBeenCalledWith(
|
||||
'genarrative:wechat-subscribe-result',
|
||||
'request-1:success',
|
||||
);
|
||||
expect(globalThis.wx.navigateBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useAuthUi } from './components/auth/AuthUiContext';
|
||||
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
|
||||
import { getInitialPlatformDesktopLayout } from './components/platform-entry/platformEntryResponsive';
|
||||
import type {
|
||||
CustomWorldRuntimeLaunchOptions,
|
||||
SelectionStage,
|
||||
} from './components/platform-entry/platformEntryTypes';
|
||||
import { useHostNavigationCanGoBack } from './hooks/useHostNavigationCanGoBack';
|
||||
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
APP_RUNTIME_ROUTES,
|
||||
isAppHistoryState,
|
||||
normalizeAppPath,
|
||||
pushAppHistoryPath,
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
replaceAppHistoryPath,
|
||||
resolveInitialSelectionStageFromPath,
|
||||
resolvePathForSelectionStage,
|
||||
shouldRedirectEditorCanvasWithoutProject,
|
||||
} from './routing/appPageRoutes';
|
||||
import type { RpgRuntimeAppIntent } from './RpgRuntimeApp';
|
||||
import {
|
||||
resolveAppTitleForSelectionStage,
|
||||
syncAppTitle,
|
||||
} from './services/appTitle';
|
||||
import {
|
||||
refreshNativeAppHostRuntime,
|
||||
subscribeHostRuntimeChange,
|
||||
} from './services/host-bridge/hostBridge';
|
||||
import type { CustomWorldProfile } from './types';
|
||||
|
||||
const RpgRuntimeApp = lazy(async () => {
|
||||
const module = await import('./RpgRuntimeApp');
|
||||
return {
|
||||
default: module.RpgRuntimeApp,
|
||||
};
|
||||
});
|
||||
|
||||
function RuntimeLoadingFallback() {
|
||||
return (
|
||||
<div className="platform-ui-shell platform-viewport-shell platform-theme platform-theme--dark flex h-screen items-center justify-center bg-[image:var(--platform-body-fill)] p-4 font-sans text-[var(--platform-text-strong)]">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
正在启动
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isRpgRuntimeRoute(pathname: string) {
|
||||
const normalizedPath = normalizeAppPath(pathname);
|
||||
return (
|
||||
normalizedPath === APP_RUNTIME_ROUTES['rpg-character-select'] ||
|
||||
normalizedPath === APP_RUNTIME_ROUTES['rpg-adventure']
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInitialAppSelectionStage() {
|
||||
if (
|
||||
shouldRedirectEditorCanvasWithoutProject(
|
||||
window.location.pathname,
|
||||
window.location.search,
|
||||
)
|
||||
) {
|
||||
replaceAppHistoryPath('/creation');
|
||||
return 'creation-home';
|
||||
}
|
||||
|
||||
return resolveInitialSelectionStageFromPath(
|
||||
window.location.pathname,
|
||||
getInitialPlatformDesktopLayout(),
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const authUi = useAuthUi();
|
||||
const runtimeIntentTokenRef = useRef(0);
|
||||
const hasHostNavigationAnchorRef = useRef(
|
||||
isAppHistoryState(window.history.state),
|
||||
);
|
||||
const hostNavigation = useHostNavigationCanGoBack();
|
||||
const [runtimeIntent, setRuntimeIntent] =
|
||||
useState<RpgRuntimeAppIntent | null>(null);
|
||||
const [, setHostRuntimeRevision] = useState(0);
|
||||
const [isRuntimeActive, setIsRuntimeActive] = useState(() =>
|
||||
isRpgRuntimeRoute(window.location.pathname),
|
||||
);
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(
|
||||
resolveInitialAppSelectionStage,
|
||||
);
|
||||
const [runtimeReturnStage, setRuntimeReturnStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
const [initialPublicWorkCode] = useState(() =>
|
||||
readPublicWorkCodeFromLocationSearch(window.location.search),
|
||||
);
|
||||
|
||||
const setSelectionStage = useCallback(
|
||||
(stage: SelectionStage, options?: { path?: string }) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(options?.path ?? resolvePathForSelectionStage(stage));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeHostRuntimeChange(() => {
|
||||
setHostRuntimeRevision((revision) => revision + 1);
|
||||
});
|
||||
|
||||
void refreshNativeAppHostRuntime();
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncStageFromHistory = () => {
|
||||
hasHostNavigationAnchorRef.current = isAppHistoryState(
|
||||
window.history.state,
|
||||
);
|
||||
|
||||
if (
|
||||
shouldRedirectEditorCanvasWithoutProject(
|
||||
window.location.pathname,
|
||||
window.location.search,
|
||||
)
|
||||
) {
|
||||
replaceAppHistoryPath('/creation');
|
||||
setIsRuntimeActive(false);
|
||||
setRawSelectionStage('creation-home');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRpgRuntimeRoute(window.location.pathname)) {
|
||||
setIsRuntimeActive(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRuntimeActive(false);
|
||||
setRawSelectionStage(
|
||||
resolveInitialSelectionStageFromPath(
|
||||
window.location.pathname,
|
||||
getInitialPlatformDesktopLayout(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', syncStageFromHistory);
|
||||
return () => window.removeEventListener('popstate', syncStageFromHistory);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hostNavigation.isSupported ||
|
||||
hostNavigation.canGoBack ||
|
||||
isRuntimeActive ||
|
||||
selectionStage === 'platform' ||
|
||||
isAppHistoryState(window.history.state) ||
|
||||
hasHostNavigationAnchorRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = normalizeAppPath(window.location.pathname);
|
||||
const currentSearch = window.location.search;
|
||||
|
||||
hasHostNavigationAnchorRef.current = true;
|
||||
replaceAppHistoryPath('/');
|
||||
pushAppHistoryPath(`${currentPath}${currentSearch}`);
|
||||
}, [
|
||||
hostNavigation.canGoBack,
|
||||
hostNavigation.isSupported,
|
||||
isRuntimeActive,
|
||||
selectionStage,
|
||||
]);
|
||||
|
||||
const createRuntimeIntent = useCallback(
|
||||
(intent: Omit<RpgRuntimeAppIntent, 'token'>) => {
|
||||
runtimeIntentTokenRef.current += 1;
|
||||
setRuntimeIntent({
|
||||
...intent,
|
||||
token: runtimeIntentTokenRef.current,
|
||||
});
|
||||
setIsRuntimeActive(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContinueGame = useCallback(
|
||||
(snapshot?: HydratedSavedGameSnapshot | null) => {
|
||||
createRuntimeIntent({
|
||||
kind: 'snapshot',
|
||||
snapshot: snapshot ?? null,
|
||||
});
|
||||
},
|
||||
[createRuntimeIntent],
|
||||
);
|
||||
|
||||
const handleCustomWorldSelect = useCallback(
|
||||
(
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => {
|
||||
// 中文注释:作品测试需要在结束测试后精确返回启动它的结果页;
|
||||
// 正式进入世界仍保持既有平台首页返回语义。
|
||||
setRuntimeReturnStage(options?.returnStage ?? 'platform');
|
||||
createRuntimeIntent({
|
||||
kind: 'custom-world',
|
||||
profile: customWorldProfile,
|
||||
mode: options?.mode ?? 'play',
|
||||
disablePersistence: options?.disablePersistence,
|
||||
exitToResult: options?.returnStage === 'custom-world-result',
|
||||
});
|
||||
},
|
||||
[createRuntimeIntent],
|
||||
);
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const isImageEditorStage = selectionStage === 'image-editor';
|
||||
const platformShellSurfaceClass = isImageEditorStage
|
||||
? 'bg-white p-0'
|
||||
: 'bg-[image:var(--platform-body-fill)] p-2 sm:p-4';
|
||||
|
||||
useEffect(() => {
|
||||
syncAppTitle(
|
||||
isRuntimeActive
|
||||
? 'RPG 运行中 - 陶泥儿'
|
||||
: resolveAppTitleForSelectionStage(selectionStage),
|
||||
);
|
||||
}, [isRuntimeActive, selectionStage]);
|
||||
|
||||
if (isRuntimeActive) {
|
||||
return (
|
||||
<Suspense fallback={<RuntimeLoadingFallback />}>
|
||||
<RpgRuntimeApp
|
||||
initialIntent={runtimeIntent}
|
||||
onExitRuntime={() => {
|
||||
setIsRuntimeActive(false);
|
||||
setSelectionStage(runtimeReturnStage);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden ${platformShellSurfaceClass} font-sans text-[var(--platform-text-strong)]`}
|
||||
>
|
||||
<PlatformEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
initialPublicWorkCode={initialPublicWorkCode}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
handleContinueGame={handleContinueGame}
|
||||
handleStartNewGame={() => {}}
|
||||
handleCustomWorldSelect={handleCustomWorldSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import { PlatformEntryFlowShellImpl } from './PlatformEntryFlowShellImpl';
|
||||
import type {
|
||||
PlatformEntryFlowShellProps,
|
||||
SelectionStage,
|
||||
} from './platformEntryTypes';
|
||||
|
||||
export type { PlatformEntryFlowShellProps, SelectionStage };
|
||||
|
||||
/**
|
||||
* 平台入口通用壳层。
|
||||
* RPG、Big Fish 等玩法创作入口在这里并列分流。
|
||||
*/
|
||||
export function PlatformEntryFlowShell(props: PlatformEntryFlowShellProps) {
|
||||
return <PlatformEntryFlowShellImpl {...props} />;
|
||||
}
|
||||
|
||||
export default PlatformEntryFlowShell;
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
|
||||
import {
|
||||
buildExternalGenerationQueuePresentation,
|
||||
buildExternalGenerationQueueStatus,
|
||||
shouldUseFastExternalGenerationTaskPolling,
|
||||
} from './platformExternalGenerationQueueStatusModel';
|
||||
import {
|
||||
resolveFinishedMiniGameDraftGenerationState,
|
||||
resolveMiniGameGenerationProgressTickState,
|
||||
resolveMiniGameGenerationViewBusy,
|
||||
} from './platformMiniGameDraftGenerationStateModel';
|
||||
|
||||
describe('resolveMiniGameGenerationProgressTickState', () => {
|
||||
test('returns jump hop and wooden fish generation states for progress ticking', () => {
|
||||
const jumpHopState = createMiniGameDraftGenerationState('jump-hop');
|
||||
const woodenFishState = createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
expect(
|
||||
resolveMiniGameGenerationProgressTickState('jump-hop-generating', {
|
||||
'jump-hop': jumpHopState,
|
||||
}),
|
||||
).toBe(jumpHopState);
|
||||
expect(
|
||||
resolveMiniGameGenerationProgressTickState('wooden-fish-generating', {
|
||||
'wooden-fish': woodenFishState,
|
||||
}),
|
||||
).toBe(woodenFishState);
|
||||
});
|
||||
|
||||
test('returns null when the stage does not need generation ticking', () => {
|
||||
expect(
|
||||
resolveMiniGameGenerationProgressTickState('platform', {
|
||||
'jump-hop': createMiniGameDraftGenerationState('jump-hop'),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMiniGameGenerationViewBusy', () => {
|
||||
test('敲木鱼恢复生成中草稿时继续隐藏重新生成按钮', () => {
|
||||
const woodenFishGeneratingState =
|
||||
createMiniGameDraftGenerationState('wooden-fish');
|
||||
|
||||
expect(
|
||||
resolveMiniGameGenerationViewBusy(false, woodenFishGeneratingState),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('生成态结束后只保留真实 busy', () => {
|
||||
const woodenFishReadyState = resolveFinishedMiniGameDraftGenerationState(
|
||||
createMiniGameDraftGenerationState('wooden-fish'),
|
||||
'ready',
|
||||
);
|
||||
|
||||
expect(resolveMiniGameGenerationViewBusy(false, woodenFishReadyState)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(resolveMiniGameGenerationViewBusy(true, woodenFishReadyState)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExternalGenerationQueueStatus', () => {
|
||||
test('合并队列概览和当前任务状态', () => {
|
||||
expect(
|
||||
buildExternalGenerationQueueStatus(
|
||||
{
|
||||
pendingCount: 7,
|
||||
runningCount: 3,
|
||||
unacknowledgedTerminalCount: 1,
|
||||
updatedAtMicros: 1_781_222_400_000_000,
|
||||
},
|
||||
{
|
||||
operationId: 'extgen-1',
|
||||
status: 'running',
|
||||
phaseLabel: '正在生成。',
|
||||
phaseDetail: '正在生成。',
|
||||
progress: 35,
|
||||
updatedAtMicros: 1_781_222_400_000_000,
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
currentStatus: 'running',
|
||||
currentProgress: 35,
|
||||
pendingCount: 7,
|
||||
runningCount: 3,
|
||||
unacknowledgedTerminalCount: 1,
|
||||
tasks: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('没有队列或任务信息时不显示状态条', () => {
|
||||
expect(buildExternalGenerationQueueStatus(null, null)).toBeNull();
|
||||
});
|
||||
|
||||
test('构造我的页生成队列展示状态', () => {
|
||||
expect(
|
||||
buildExternalGenerationQueuePresentation({
|
||||
currentStatus: 'running',
|
||||
currentProgress: 42.4,
|
||||
pendingCount: 2,
|
||||
runningCount: 1,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
statusLabel: '生成中',
|
||||
progressLabel: '42%',
|
||||
pendingLabel: '2',
|
||||
runningLabel: '1',
|
||||
pendingCount: 2,
|
||||
runningCount: 1,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
progress: 42,
|
||||
tasks: [],
|
||||
shouldShow: true,
|
||||
});
|
||||
|
||||
expect(buildExternalGenerationQueuePresentation(null).shouldShow).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
buildExternalGenerationQueuePresentation({
|
||||
currentStatus: 'completed',
|
||||
currentProgress: 100,
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
}).shouldShow,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('只在队列活跃或存在未确认终态任务时使用快速轮询', () => {
|
||||
expect(
|
||||
shouldUseFastExternalGenerationTaskPolling({
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
tasks: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldUseFastExternalGenerationTaskPolling({
|
||||
currentStatus: 'queued',
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
tasks: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldUseFastExternalGenerationTaskPolling({
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
tasks: [
|
||||
{
|
||||
jobId: 'extgen-completed',
|
||||
jobKind: 'editor_image_generation',
|
||||
sourceModule: 'editor',
|
||||
sourceEntityId: 'project-1',
|
||||
requestLabel: '图片生成',
|
||||
status: 'completed',
|
||||
phaseLabel: '已完成',
|
||||
phaseDetail: '已完成',
|
||||
progress: 100,
|
||||
priceMudPoints: 1,
|
||||
createdAt: '2026-06-25T00:00:00.000Z',
|
||||
updatedAt: '2026-06-25T00:00:00.000Z',
|
||||
updatedAtMicros: 1_782_348_800_000_000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
-18264
File diff suppressed because it is too large
Load Diff
-102
@@ -1,102 +0,0 @@
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type CustomWorldRuntimeLaunchMode = 'play';
|
||||
|
||||
export type CustomWorldRuntimeLaunchOptions = {
|
||||
mode?: CustomWorldRuntimeLaunchMode;
|
||||
disablePersistence?: boolean;
|
||||
returnStage?: SelectionStage | null;
|
||||
};
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'creation-home'
|
||||
| 'project'
|
||||
| 'image-editor'
|
||||
| 'profile-feedback'
|
||||
| 'work-detail'
|
||||
| 'detail'
|
||||
| 'agent-workspace'
|
||||
| 'big-fish-agent-workspace'
|
||||
| 'big-fish-generating'
|
||||
| 'big-fish-result'
|
||||
| 'big-fish-runtime'
|
||||
| 'match3d-agent-workspace'
|
||||
| 'match3d-generating'
|
||||
| 'match3d-result'
|
||||
| 'match3d-runtime'
|
||||
| 'square-hole-agent-workspace'
|
||||
| 'square-hole-generating'
|
||||
| 'square-hole-result'
|
||||
| 'square-hole-runtime'
|
||||
| 'jump-hop-workspace'
|
||||
| 'jump-hop-generating'
|
||||
| 'jump-hop-result'
|
||||
| 'jump-hop-runtime'
|
||||
| 'jump-hop-gallery-detail'
|
||||
| 'puzzle-clear-workspace'
|
||||
| 'puzzle-clear-generating'
|
||||
| 'puzzle-clear-result'
|
||||
| 'puzzle-clear-runtime'
|
||||
| 'bark-battle-workspace'
|
||||
| 'bark-battle-generating'
|
||||
| 'bark-battle-result'
|
||||
| 'bark-battle-runtime'
|
||||
| 'wooden-fish-workspace'
|
||||
| 'wooden-fish-generating'
|
||||
| 'wooden-fish-result'
|
||||
| 'wooden-fish-runtime'
|
||||
| 'creative-agent-workspace'
|
||||
| 'visual-novel-agent-workspace'
|
||||
| 'visual-novel-generating'
|
||||
| 'visual-novel-result'
|
||||
| 'visual-novel-gallery-detail'
|
||||
| 'visual-novel-runtime'
|
||||
| 'baby-object-match-workspace'
|
||||
| 'baby-object-match-generating'
|
||||
| 'baby-object-match-result'
|
||||
| 'baby-object-match-runtime'
|
||||
| 'baby-love-drawing-runtime'
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-generating'
|
||||
| 'puzzle-onboarding'
|
||||
| 'puzzle-result'
|
||||
| 'puzzle-gallery-detail'
|
||||
| 'puzzle-runtime'
|
||||
| 'custom-world-generating'
|
||||
| 'custom-world-result';
|
||||
|
||||
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
|
||||
|
||||
export type CustomWorldResultViewSource =
|
||||
| 'saved-profile'
|
||||
| 'agent-draft'
|
||||
| null;
|
||||
|
||||
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
export type SyncedAgentDraftResult = {
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
profile: CustomWorldProfile | null;
|
||||
view?: RpgCreationResultView | null;
|
||||
};
|
||||
|
||||
export type PlatformEntryFlowShellProps = {
|
||||
selectionStage: SelectionStage;
|
||||
setSelectionStage: (
|
||||
stage: SelectionStage,
|
||||
options?: { path?: string },
|
||||
) => void;
|
||||
initialPublicWorkCode?: string | null;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||
handleStartNewGame: () => void;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => void;
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import './index.css';
|
||||
|
||||
import { StrictMode, Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { FloatingFeedbackEntry } from './components/common/FloatingFeedbackEntry';
|
||||
import { getInitialPlatformDesktopLayout } from './components/platform-entry/platformEntryResponsive';
|
||||
import { stabilizeMobileViewportKeyboardFocus } from './mobileViewportKeyboardFocus';
|
||||
import { lockMobileViewportZoom } from './mobileViewportZoomLock';
|
||||
import {
|
||||
resolveAppRoute,
|
||||
shouldUseRecommendationRouteLoading,
|
||||
} from './routing/appRoutes';
|
||||
import { RouteImageReadyGate } from './routing/RouteImageReadyGate';
|
||||
import { RouteLoadingScreen } from './routing/RouteLoadingScreen';
|
||||
import {
|
||||
getHostRuntime,
|
||||
refreshNativeAppHostRuntime,
|
||||
} from './services/host-bridge/hostBridge';
|
||||
|
||||
type AppRoot = ReturnType<typeof createRoot>;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__tavernRealmsRoot__?: AppRoot;
|
||||
}
|
||||
}
|
||||
|
||||
const route = resolveAppRoute(window.location.pathname);
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Missing #root container');
|
||||
}
|
||||
|
||||
function markWechatMiniProgramRuntime() {
|
||||
if (getHostRuntime().kind === 'wechat_mini_program') {
|
||||
document.documentElement.dataset.wechatMiniProgramRuntime = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
const root = (window.__tavernRealmsRoot__ ??= createRoot(rootElement));
|
||||
const RouteComponent = route.Component;
|
||||
const routeElement = <RouteComponent {...(route.componentProps ?? {})} />;
|
||||
const shouldGateRouteImages = shouldUseRecommendationRouteLoading(
|
||||
window.location.pathname,
|
||||
getInitialPlatformDesktopLayout(),
|
||||
);
|
||||
|
||||
lockMobileViewportZoom();
|
||||
stabilizeMobileViewportKeyboardFocus();
|
||||
markWechatMiniProgramRuntime();
|
||||
void refreshNativeAppHostRuntime();
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<Suspense
|
||||
fallback={
|
||||
shouldGateRouteImages ? (
|
||||
<RouteLoadingScreen
|
||||
eyebrow={route.loadingEyebrow}
|
||||
text={route.loadingText}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{shouldGateRouteImages ? (
|
||||
<RouteImageReadyGate
|
||||
eyebrow={route.loadingEyebrow}
|
||||
text={route.loadingText}
|
||||
>
|
||||
{routeElement}
|
||||
</RouteImageReadyGate>
|
||||
) : (
|
||||
routeElement
|
||||
)}
|
||||
</Suspense>
|
||||
<FloatingFeedbackEntry />
|
||||
</StrictMode>,
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user