Merge branch 'master' into feat/ui-editor-edit-history
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,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
@@ -40,9 +41,34 @@ test('manifest contains version, download URL and integrity fields', () => {
|
||||
assert.equal(typeof manifest.size, 'number');
|
||||
});
|
||||
|
||||
test('manifest preserves multiline release notes', () => {
|
||||
const previous = process.env.AGC_UPDATE_RELEASE_NOTES;
|
||||
process.env.AGC_UPDATE_RELEASE_NOTES = '第一行\n第二行\r\n第三行';
|
||||
try {
|
||||
const manifest = createUpdateManifest(
|
||||
new URL('../package.json', import.meta.url).pathname,
|
||||
);
|
||||
assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AGC_UPDATE_RELEASE_NOTES;
|
||||
else process.env.AGC_UPDATE_RELEASE_NOTES = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('next release version follows the higher local or OSS version', () => {
|
||||
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
|
||||
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
|
||||
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
|
||||
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
|
||||
});
|
||||
|
||||
test('release upload forces overwrite for versioned artifact and latest pointer', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(
|
||||
(source.match(/runOssutil\(\['cp', '--force'/gu) ?? []).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,9 @@ await prepareReleaseVersion();
|
||||
runTauriBuild([]);
|
||||
const { artifact, manifestPath, manifest } = generateUpdateManifest();
|
||||
const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`;
|
||||
runOssutil(['cp', artifact, `oss://${bucket}/${artifactKey}`]);
|
||||
runOssutil(['cp', manifestPath, `oss://${bucket}/agc/latest.json`]);
|
||||
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
|
||||
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
|
||||
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
|
||||
runOssutil(['cp', '--force', manifestPath, `oss://${bucket}/agc/latest.json`]);
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/latest.json`);
|
||||
|
||||
@@ -79,6 +79,7 @@ function resolveBackendTargetsFromState(
|
||||
) {
|
||||
const apiServer = state?.services?.['api-server'];
|
||||
const spacetime = state?.services?.spacetime;
|
||||
const bgfilterWorker = state?.services?.['bgfilter-worker'];
|
||||
const isActive = (service) =>
|
||||
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
|
||||
const database = typeof state?.database === 'string' ? state.database : '';
|
||||
@@ -104,9 +105,14 @@ function resolveBackendTargetsFromState(
|
||||
: requireAgcBackend
|
||||
? ''
|
||||
: 'http://127.0.0.1:3101';
|
||||
const bgfilterWorkerUrl =
|
||||
canReuseState && isActive(bgfilterWorker) && bgfilterWorker.url
|
||||
? bgfilterWorker.url
|
||||
: '';
|
||||
return {
|
||||
apiUrl,
|
||||
spacetimeUrl,
|
||||
bgfilterWorkerUrl,
|
||||
database,
|
||||
spacetimeDataDir,
|
||||
hasMatchingDatabase,
|
||||
@@ -121,16 +127,22 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function isBackendReady() {
|
||||
const { apiUrl, spacetimeUrl, hasMatchingBackend } = readBackendTargets({
|
||||
requireAgcBackend: true,
|
||||
});
|
||||
async function isBackendReady({
|
||||
state = readJson(devStackStatePath),
|
||||
isReady = isHttpReady,
|
||||
} = {}) {
|
||||
const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } =
|
||||
resolveBackendTargetsFromState(state, {
|
||||
requireAgcBackend: true,
|
||||
});
|
||||
return (
|
||||
hasMatchingBackend &&
|
||||
Boolean(apiUrl) &&
|
||||
Boolean(spacetimeUrl) &&
|
||||
(await isHttpReady(`${apiUrl}/healthz`)) &&
|
||||
(await isHttpReady(`${spacetimeUrl}/v1/ping`))
|
||||
Boolean(bgfilterWorkerUrl) &&
|
||||
(await isReady(`${apiUrl}/healthz`)) &&
|
||||
(await isReady(`${spacetimeUrl}/v1/ping`)) &&
|
||||
(await isReady(`${bgfilterWorkerUrl}/readyz`))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -668,6 +680,7 @@ function isDirectModuleExecution() {
|
||||
export {
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
isBackendReady,
|
||||
isDirectModuleExecution,
|
||||
isProcessGroupAlive,
|
||||
preflightExistingVite,
|
||||
|
||||
@@ -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';
|
||||
+52
-10
@@ -18,6 +18,11 @@ export type LocalGamePreviewFitLayout = {
|
||||
scale: number;
|
||||
};
|
||||
|
||||
type LocalGamePreviewViewportSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type LocalGamePreviewLike = {
|
||||
status?: string | null;
|
||||
url?: string | null;
|
||||
@@ -91,13 +96,30 @@ export function resolveLocalGamePreviewFitLayout(
|
||||
export function resolveLocalGamePreviewContentSizeUpdate(
|
||||
current: LocalGamePreviewContentSize | null,
|
||||
next: LocalGamePreviewContentSize,
|
||||
nativeViewport: { width: number; height: number },
|
||||
appliedViewport: { width: number; height: number },
|
||||
nativeViewport: LocalGamePreviewViewportSize,
|
||||
appliedViewport: LocalGamePreviewViewportSize,
|
||||
previousReportedViewport: LocalGamePreviewViewportSize | null = null,
|
||||
): LocalGamePreviewContentSize | null {
|
||||
const reportsViewport = (viewport: { width: number; height: number }) =>
|
||||
Math.abs(next.viewportWidth - viewport.width) < 1 &&
|
||||
Math.abs(next.viewportHeight - viewport.height) < 1;
|
||||
if (!reportsViewport(nativeViewport) && !reportsViewport(appliedViewport)) {
|
||||
const reportsNativeViewport = previewReportMatchesViewport(
|
||||
next,
|
||||
nativeViewport,
|
||||
);
|
||||
const reportsAppliedViewport = previewReportMatchesViewport(
|
||||
next,
|
||||
appliedViewport,
|
||||
);
|
||||
if (!reportsNativeViewport && !reportsAppliedViewport) {
|
||||
return current;
|
||||
}
|
||||
const followsViewportChange =
|
||||
previousReportedViewport !== null &&
|
||||
!previewReportMatchesViewport(next, previousReportedViewport);
|
||||
if (
|
||||
current &&
|
||||
reportsAppliedViewport &&
|
||||
!reportsNativeViewport &&
|
||||
followsViewportChange
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
@@ -114,6 +136,16 @@ export function resolveLocalGamePreviewContentSizeUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
function previewReportMatchesViewport(
|
||||
report: LocalGamePreviewContentSize,
|
||||
viewport: LocalGamePreviewViewportSize,
|
||||
) {
|
||||
return (
|
||||
Math.abs(report.viewportWidth - viewport.width) < 1 &&
|
||||
Math.abs(report.viewportHeight - viewport.height) < 1
|
||||
);
|
||||
}
|
||||
|
||||
export function LocalGamePreviewFrame({
|
||||
preview,
|
||||
title,
|
||||
@@ -128,6 +160,9 @@ export function LocalGamePreviewFrame({
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const measuredContainerSizeRef = useRef({ width: 1, height: 1 });
|
||||
const contentSizeRef = useRef<LocalGamePreviewContentSize | null>(null);
|
||||
const reportedViewportSizeRef = useRef<LocalGamePreviewViewportSize | null>(
|
||||
null,
|
||||
);
|
||||
const [containerSize, setContainerSize] = useState({ width: 1, height: 1 });
|
||||
const [contentSize, setContentSize] =
|
||||
useState<LocalGamePreviewContentSize | null>(null);
|
||||
@@ -150,10 +185,6 @@ export function LocalGamePreviewFrame({
|
||||
}
|
||||
measuredContainerSizeRef.current = next;
|
||||
setContainerSize(next);
|
||||
if (contentSizeRef.current) {
|
||||
contentSizeRef.current = null;
|
||||
setContentSize(null);
|
||||
}
|
||||
};
|
||||
update();
|
||||
if (typeof window.ResizeObserver === 'function') {
|
||||
@@ -166,6 +197,7 @@ export function LocalGamePreviewFrame({
|
||||
}, [embeddedUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
reportedViewportSizeRef.current = null;
|
||||
if (contentSizeRef.current) {
|
||||
contentSizeRef.current = null;
|
||||
setContentSize(null);
|
||||
@@ -190,12 +222,22 @@ export function LocalGamePreviewFrame({
|
||||
nativeViewport,
|
||||
current,
|
||||
);
|
||||
const reportsCurrentViewport =
|
||||
previewReportMatchesViewport(next, nativeViewport) ||
|
||||
previewReportMatchesViewport(next, appliedViewport);
|
||||
const resolved = resolveLocalGamePreviewContentSizeUpdate(
|
||||
current,
|
||||
next,
|
||||
nativeViewport,
|
||||
appliedViewport,
|
||||
reportedViewportSizeRef.current,
|
||||
);
|
||||
if (reportsCurrentViewport) {
|
||||
reportedViewportSizeRef.current = {
|
||||
width: next.viewportWidth,
|
||||
height: next.viewportHeight,
|
||||
};
|
||||
}
|
||||
if (resolved === current) return;
|
||||
contentSizeRef.current = resolved;
|
||||
setContentSize(resolved);
|
||||
|
||||
@@ -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], };
|
||||
@@ -55,9 +55,10 @@ body {
|
||||
}
|
||||
.app-update-notice p {
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.app-update-notice button {
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -32,4 +32,16 @@ describe('AGC update manifest', () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves multiline release notes', () => {
|
||||
expect(
|
||||
parseAppUpdateManifest({
|
||||
version: '0.1.13',
|
||||
downloadUrl: 'https://oss.example/agc.exe',
|
||||
releaseNotes: '第一行\n第二行\r\n第三行',
|
||||
}),
|
||||
).toMatchObject({
|
||||
releaseNotes: '第一行\n第二行\r\n第三行',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
|
||||
LocalGamePreviewFrame,
|
||||
parseLocalGamePreviewContentSize,
|
||||
resolveLocalGamePreviewContentSizeUpdate,
|
||||
resolveLocalGamePreviewFitLayout,
|
||||
} from '../src/features/project-workspace/LocalGamePreviewFrame';
|
||||
|
||||
describe('local game preview viewport fitting', () => {
|
||||
it('does not reset the fitted iframe to native size while its container resizes', () => {
|
||||
let containerRect = { width: 800, height: 500 };
|
||||
let resizeCallback: ResizeObserverCallback | null = null;
|
||||
const rectSpy = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
...containerRect,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
right: containerRect.width,
|
||||
bottom: containerRect.height,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect,
|
||||
);
|
||||
const previousResizeObserver = window.ResizeObserver;
|
||||
window.ResizeObserver = class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
const view = render(
|
||||
createElement(LocalGamePreviewFrame, {
|
||||
preview: { status: 'running', url: 'http://127.0.0.1:1234/' },
|
||||
title: 'preview',
|
||||
}),
|
||||
);
|
||||
const iframe = view.getByTitle('preview') as HTMLIFrameElement;
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin: 'http://127.0.0.1:1234',
|
||||
source: iframe.contentWindow,
|
||||
data: {
|
||||
type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
|
||||
contentWidth: 800,
|
||||
contentHeight: 835,
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 500,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(iframe.style.height).toBe('835px');
|
||||
|
||||
containerRect = { width: 1000, height: 600 };
|
||||
act(() => {
|
||||
if (!resizeCallback) throw new Error('ResizeObserver was not registered');
|
||||
resizeCallback([], {} as ResizeObserver);
|
||||
});
|
||||
expect(iframe.style.width).toBe('1000px');
|
||||
expect(iframe.style.height).toBe('835px');
|
||||
|
||||
view.unmount();
|
||||
window.ResizeObserver = previousResizeObserver;
|
||||
rectSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps the current fit while the iframe reports its first host-applied viewport measurement', () => {
|
||||
const nativeViewport = { width: 1200, height: 700 };
|
||||
const appliedViewport = { width: 1200, height: 1000 };
|
||||
const current = {
|
||||
contentWidth: 1200,
|
||||
contentHeight: 1000,
|
||||
viewportWidth: 1200,
|
||||
viewportHeight: 700,
|
||||
};
|
||||
const firstAppliedViewportReport = {
|
||||
contentWidth: 1200,
|
||||
contentHeight: 700,
|
||||
viewportWidth: 1200,
|
||||
viewportHeight: 1000,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveLocalGamePreviewContentSizeUpdate(
|
||||
current,
|
||||
firstAppliedViewportReport,
|
||||
nativeViewport,
|
||||
appliedViewport,
|
||||
nativeViewport,
|
||||
),
|
||||
).toBe(current);
|
||||
});
|
||||
|
||||
it('does not let a delayed native report restart the fitted viewport loop', () => {
|
||||
const nativeViewport = { width: 1200, height: 700 };
|
||||
const appliedViewport = { width: 1200, height: 1000 };
|
||||
const current = {
|
||||
contentWidth: 1200,
|
||||
contentHeight: 1000,
|
||||
viewportWidth: 1200,
|
||||
viewportHeight: 700,
|
||||
};
|
||||
const delayedNativeReport = {
|
||||
contentWidth: 1200,
|
||||
contentHeight: 1000,
|
||||
viewportWidth: 1200,
|
||||
viewportHeight: 700,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveLocalGamePreviewContentSizeUpdate(
|
||||
current,
|
||||
delayedNativeReport,
|
||||
nativeViewport,
|
||||
appliedViewport,
|
||||
appliedViewport,
|
||||
),
|
||||
).toBe(current);
|
||||
});
|
||||
|
||||
it('keeps the current fit while a resized container applies its next viewport', () => {
|
||||
const resizedContainer = { width: 1000, height: 600 };
|
||||
const current = {
|
||||
contentWidth: 800,
|
||||
contentHeight: 835,
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 500,
|
||||
};
|
||||
const appliedViewport = resolveLocalGamePreviewFitLayout(
|
||||
resizedContainer,
|
||||
current,
|
||||
);
|
||||
const resizedViewportReport = {
|
||||
contentWidth: 1000,
|
||||
contentHeight: 818,
|
||||
viewportWidth: appliedViewport.width,
|
||||
viewportHeight: appliedViewport.height,
|
||||
};
|
||||
|
||||
expect(appliedViewport).toEqual({
|
||||
width: 1000,
|
||||
height: 835,
|
||||
scale: 600 / 835,
|
||||
});
|
||||
expect(
|
||||
resolveLocalGamePreviewContentSizeUpdate(
|
||||
current,
|
||||
resizedViewportReport,
|
||||
resizedContainer,
|
||||
appliedViewport,
|
||||
{ width: 800, height: 835 },
|
||||
),
|
||||
).toBe(current);
|
||||
});
|
||||
|
||||
it('keeps a game at native size when its content fits', () => {
|
||||
expect(
|
||||
resolveLocalGamePreviewFitLayout(
|
||||
@@ -55,7 +215,7 @@ describe('local game preview viewport fitting', () => {
|
||||
).toEqual(report);
|
||||
});
|
||||
|
||||
it('accepts changed content after the iframe has adopted the first fit viewport', () => {
|
||||
it('accepts changed content after the fitted iframe viewport has stabilized', () => {
|
||||
const nativeViewport = { width: 1200, height: 700 };
|
||||
const current = {
|
||||
contentWidth: 1200,
|
||||
@@ -79,6 +239,7 @@ describe('local game preview viewport fitting', () => {
|
||||
width: 1200,
|
||||
height: 1000,
|
||||
},
|
||||
{ width: 1200, height: 1000 },
|
||||
),
|
||||
).toEqual({
|
||||
contentWidth: 1200,
|
||||
@@ -88,7 +249,7 @@ describe('local game preview viewport fitting', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shrinks the fitted frame when content becomes shorter inside the applied viewport', () => {
|
||||
it('shrinks the fitted frame when content becomes shorter inside a stable applied viewport', () => {
|
||||
const nativeViewport = { width: 1200, height: 700 };
|
||||
const current = {
|
||||
contentWidth: 1200,
|
||||
@@ -108,6 +269,7 @@ describe('local game preview viewport fitting', () => {
|
||||
changed,
|
||||
nativeViewport,
|
||||
{ width: 1200, height: 1000 },
|
||||
{ width: 1200, height: 1000 },
|
||||
);
|
||||
expect(updated).toEqual({
|
||||
contentWidth: 1200,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureBackend,
|
||||
isBackendReady,
|
||||
isProcessGroupAlive,
|
||||
preflightExistingVite,
|
||||
readLinuxProcessGroupAlive,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
const expectedDatabase = 'genarrative-game-creator-dev';
|
||||
const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data');
|
||||
|
||||
function backendState(spacetimeDataDir?: string) {
|
||||
function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) {
|
||||
return {
|
||||
schemaVersion: spacetimeDataDir ? 2 : 1,
|
||||
database: expectedDatabase,
|
||||
@@ -35,6 +36,14 @@ function backendState(spacetimeDataDir?: string) {
|
||||
status: 'running',
|
||||
url: 'http://127.0.0.1:3101',
|
||||
},
|
||||
...(includeBgfilterWorker
|
||||
? {
|
||||
'bgfilter-worker': {
|
||||
status: 'running',
|
||||
url: 'http://127.0.0.1:8083',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -87,6 +96,36 @@ describe('AI 游戏创作配套后端复用门禁', () => {
|
||||
expect(matching.hasMatchingBackend).toBe(true);
|
||||
expect(matching.apiUrl).toBe('http://127.0.0.1:8082');
|
||||
expect(matching.spacetimeUrl).toBe('http://127.0.0.1:3101');
|
||||
expect(matching.bgfilterWorkerUrl).toBe('http://127.0.0.1:8083');
|
||||
});
|
||||
|
||||
test('worker 缺失或未 ready 时不允许复用后端', async () => {
|
||||
const isReady = vi.fn(async (_url: string) => true);
|
||||
|
||||
await expect(
|
||||
isBackendReady({
|
||||
state: backendState(expectedDataDir, false),
|
||||
isReady,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(isReady).not.toHaveBeenCalled();
|
||||
|
||||
isReady.mockImplementation(async (url) => url.endsWith('/readyz'));
|
||||
await expect(
|
||||
isBackendReady({
|
||||
state: backendState(expectedDataDir),
|
||||
isReady,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz');
|
||||
|
||||
isReady.mockImplementation(async () => true);
|
||||
await expect(
|
||||
isBackendReady({
|
||||
state: backendState(expectedDataDir),
|
||||
isReady,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,11 +60,11 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
|
||||
|
||||
### Jenkins 预览 secrets 镜像边界
|
||||
|
||||
Jenkins 分支预览构建固定从宿主 `/data/jenkins/preview-secrets/.env.secrets.local` 读取 secrets。目录由 Jenkins 运行账号所有且权限为 `0700`,文件由同一账号所有且权限为 `0600`;构建入口对缺失、链接、非普通文件、owner 不匹配和过宽权限均失败关闭。不要把真实值写入本 README、仓库示例或 Jenkins 参数。
|
||||
Jenkins 分支预览构建固定从宿主 `/data/jenkins/preview-secrets/.env.local` 与 `/data/jenkins/preview-secrets/.env.secrets.local` 读取运行时配置。目录由 Jenkins 运行账号所有且权限为 `0700`,两个文件由同一账号所有且权限为 `0600`;构建入口对缺失、链接、非普通文件、owner 不匹配和过宽权限均失败关闭。两个文件都包含敏感配置,不要把真实值写入本 README、仓库示例或 Jenkins 参数。
|
||||
|
||||
该文件不复制到源码 checkout 和 Docker build context,而是以 BuildKit `secret` mount 只提供给 `api-runtime` stage。构建会把它安装到 API 运行镜像的 `/srv/genarrative/.env.secrets.local`,owner 为 `genarrative`、权限为 `0400`。Web builder、`nginx-runtime`、SpacetimeDB 和其它运行镜像不得获得该 mount 或目标文件;构建日志和 artifact 也不得回显或保存文件内容。容器的显式运行环境变量优先于该内置文件,可按预览实例覆盖其中的值。
|
||||
两个文件都不复制到源码 checkout 和 Docker build context,而是分别以 BuildKit `secret` mount 只提供给 `api-runtime` stage。构建会把它们安装到 API 运行镜像的 `/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local`,owner 为 `genarrative`、权限为 `0400`。Web builder、`nginx-runtime`、SpacetimeDB 和其它运行镜像不得获得这些 mount 或目标文件;构建日志和 artifact 也不得回显或保存文件内容。容器的显式运行环境变量优先于这两个内置文件,可按预览实例覆盖其中的值。
|
||||
|
||||
修改宿主固定文件后必须重新构建并替换 API 镜像;重启旧容器不会读取宿主新内容。这个镜像不是可公开分发的无密钥产物:镜像持有者可以提取 `/srv/genarrative/.env.secrets.local`。只允许在当前受信任内网 Docker 主机使用,禁止 push 或 `docker save`、artifact 导出到跨信任边界的 registry、主机或存储。
|
||||
修改任一宿主固定文件后必须重新构建并替换 API 与 worker 镜像;重启旧容器不会读取宿主新内容。这个镜像不是可公开分发的无密钥产物:镜像持有者可以提取 `/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local`。只允许在当前受信任内网 Docker 主机使用,禁止 push 或 `docker save`、artifact 导出到跨信任边界的 registry、主机或存储。
|
||||
|
||||
### Gitea CI 预构建 Job 镜像
|
||||
|
||||
|
||||
@@ -24,12 +24,20 @@ RUN mkdir -p /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var
|
||||
chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative
|
||||
|
||||
ARG GENARRATIVE_PREVIEW_SECRETS_SHA256=
|
||||
ARG GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256=
|
||||
RUN --mount=type=secret,id=genarrative_preview_secrets,required=false \
|
||||
--mount=type=secret,id=genarrative_preview_env_local,required=false \
|
||||
if [ -n "${GENARRATIVE_PREVIEW_SECRETS_SHA256}" ]; then \
|
||||
test -f /run/secrets/genarrative_preview_secrets; \
|
||||
test "$(sha256sum /run/secrets/genarrative_preview_secrets | cut -d ' ' -f 1)" = "${GENARRATIVE_PREVIEW_SECRETS_SHA256}"; \
|
||||
install -o genarrative -g genarrative -m 0400 \
|
||||
/run/secrets/genarrative_preview_secrets /srv/genarrative/.env.secrets.local; \
|
||||
fi; \
|
||||
if [ -n "${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256}" ]; then \
|
||||
test -f /run/secrets/genarrative_preview_env_local; \
|
||||
test "$(sha256sum /run/secrets/genarrative_preview_env_local | cut -d ' ' -f 1)" = "${GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256}"; \
|
||||
install -o genarrative -g genarrative -m 0400 \
|
||||
/run/secrets/genarrative_preview_env_local /srv/genarrative/.env.local; \
|
||||
fi
|
||||
|
||||
USER genarrative
|
||||
|
||||
@@ -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/**` 归档及 `packages/shared/src/contracts/**` 中已无仓库内消费者的旧玩法公共契约;`legacy_schema/**`、生成表 bindings、现役 shared contracts 和持久化 schema 不在本次删除范围。
|
||||
|
||||
## 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 伪装成资产成功响应。
|
||||
@@ -7794,8 +7804,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
## 2026-08-23 AI 游戏运行视窗按预览文档实际尺寸自适应
|
||||
|
||||
- 背景:项目开发工作台的中央运行视窗尺寸小于部分生成游戏的页面布局高度时,滚动条来自 loopback iframe 内部;宿主只隐藏 overflow 会直接裁掉标题、Canvas 或控制区,不能满足完整试玩。
|
||||
- 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;首次缩放后仍接受内容宽高真实变化,重复内容尺寸或仅 viewport 回灌不更新状态,容器 resize 后重新以原生视口测量。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。
|
||||
- 验证:前端纯函数覆盖无需缩放、纵向超高缩放、首次缩放后的增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以桌面最小窗口和更高窗口人工确认完整画面、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。
|
||||
- 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;宿主把最近一次合法上报的 viewport 与正式内容尺寸分开保存,首次收到自身 fit 切换产生的新 viewport 测量时只推进观察值、不反向改写 fit,viewport 稳定后的真实内容增减仍可重新适配。容器 resize 期间保留当前内容尺寸和已观察 viewport,只按新的可用空间连续重算缩放,避免拖动窗口时在原生尺寸与 fit 之间闪烁;preview URL 变化时才清空两者并重新测量。陈旧 viewport、重复内容尺寸和首次宿主回灌均不更新状态。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。
|
||||
- 验证:前端组件测试锁定容器 resize 时 iframe 不恢复原生尺寸;纯函数覆盖无需缩放、纵向超高缩放、宿主首次应用 viewport 时保持当前 fit、容器 resize 后保持当前 fit、稳定 viewport 下内容增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以 Issue #250 附件的 `min-height: 100vh` 页面在桌面最小窗口和更高窗口人工确认完整画面、无循环缩放、拖动窗口时无原生尺寸闪切、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。
|
||||
|
||||
## 2026-08-23 Direct Codex 显式重生成与切片一等资源
|
||||
|
||||
|
||||
@@ -4926,8 +4926,8 @@
|
||||
|
||||
- 现象:构建时使用 BuildKit secret mount,日志和普通 build context 都没有出现明文,于是误以为最终镜像也能不可提取地保存 secrets,随后将镜像 push 或导出给不同信任域。
|
||||
- 原因:BuildKit secret mount 只避免秘密作为 `ARG` / `COPY` 进入构建上下文和中间指令;一旦 Dockerfile 把 mount 的内容安装到最终 rootfs,任何能读取、保存或运行该镜像的主体都可以提取它。
|
||||
- 处理:预览固定 secrets 只从 Jenkins 宿主受控路径读取,严格校验目录 `0700`、文件 `0600`、owner、普通文件与非链接边界;只将其安装到 `api-runtime:/srv/genarrative/.env.secrets.local` 并设为 `0400`,明确排除 Nginx、Web、artifact 和其它镜像。镜像禁止推送或导出到跨信任边界。
|
||||
- 更新与验证:源文件变更不会改动已存镜像,必须重建并替换容器;不能用重启代替。验收同时扫描 transcript/context/artifact 零泄漏,检查只有 API 最终 rootfs 存在目标文件,并验证容器显式运行 env 优先覆盖内置值。
|
||||
- 处理:预览固定 `.env.local` 与 secrets 只从 Jenkins 宿主受控路径读取,严格校验目录 `0700`、文件 `0600`、owner、普通文件与非链接边界;只将它们安装到 `api-runtime:/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local` 并设为 `0400`,明确排除 Nginx、Web、artifact 和其它镜像。镜像禁止推送或导出到跨信任边界。
|
||||
- 更新与验证:任一源文件变更不会改动已存镜像,必须重建并替换 API 与 worker 容器;不能用重启代替。验收同时扫描 transcript/context/artifact 零泄漏,检查只有 API 与 worker 最终 rootfs 存在目标文件,并验证容器显式运行 env 优先覆盖内置值。
|
||||
|
||||
## SpacetimeDB ping 健康不代表完整模块能在内存上限内实例化(2026-08-22)
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r
|
||||
|
||||
## 预览 secrets 内置
|
||||
|
||||
Jenkins 节点上的预览 secrets 只允许来自受控的 Jenkins 凭据目录(目录与文件权限、owner、普通文件和非链接约束由流水线检查)。该文件不进 Git、Docker build context、构建日志或 artifact;构建时只通过 BuildKit `secret` mount 临时提供给 `api-runtime` stage,运行镜像权限固定为 `0400`。`nginx-runtime`、Web 静态产物、SpacetimeDB 镜像及其它镜像不得包含该文件。
|
||||
Jenkins 节点上的预览 `.env.local` 与 secrets 只允许来自受控的 Jenkins 凭据目录(目录与文件权限、owner、普通文件和非链接约束由流水线检查)。固定宿主副本不进 Git、Docker build context、构建日志或 artifact;构建时分别通过两个 BuildKit `secret` mount 临时提供给 `api-runtime` stage,并安装为 `/srv/genarrative/.env.local` 与 `/srv/genarrative/.env.secrets.local`,运行镜像权限固定为 `0400`。`nginx-runtime`、Web 静态产物、SpacetimeDB 镜像及其它镜像不得包含这些文件;仓库工作区 `.env.local` 不得替代固定宿主副本。
|
||||
|
||||
宿主固定目录应由 Jenkins 运行账号所有且权限为 `0700`,源文件权限为 `0600`;缺失、不是普通文件、owner 不匹配或权限过宽时,预览构建必须失败关闭。源文件变更后必须重新构建并替换预览镜像,只重启容器不会刷新已内置的内容。容器启动时显式注入的运行环境变量优先级高于镜像内的 `.env.secrets.local`,用于按实例覆盖非通用值。
|
||||
宿主固定目录应由 Jenkins 运行账号所有且权限为 `0700`,两个源文件权限均为 `0600`;缺失、不是普通文件、owner 不匹配或权限过宽时,预览构建必须失败关闭。任一源文件变更后必须重新构建并替换 API 与 worker 预览镜像,只重启容器不会刷新已内置的内容。容器启动时显式注入的运行环境变量优先级高于镜像内的 `.env.local` 与 `.env.secrets.local`,用于按实例覆盖非通用值。
|
||||
|
||||
这种方案只隐藏构建传输过程,不能让内置后的 secrets 对镜像持有者保密:能读取、保存或运行 `api-runtime` 镜像的人可以提取该文件。因此该镜像只能留在当前受信任内网 Docker 主机,禁止 push 到公共或跨信任边界的 registry,也禁止通过 `docker save`/构建 artifact 导出传播。需要跨边界分发时必须改用不含 secrets 的镜像与运行时密钥注入。
|
||||
|
||||
@@ -118,7 +118,7 @@ Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短
|
||||
- Jenkins service account 只授予 `shared/Genarrative-Preview-Deployer` 的 `Job/Read`、`Job/Build` 和读取构建产物所需权限,不授 `Overall/Administer`、`Job/Configure` 或 `Job/Delete`。
|
||||
- 后端固定 Jenkins origin、Job 路径和参数白名单;客户端不能传 URL、Job 名、Compose project、容器名、宿主端口或 Jenkins 凭据。
|
||||
- Git 查询固定使用本机 Gitea SSH 地址和服务端只读凭据;客户端不能传 remote、SSH 参数或凭据。Git 缓存只写入预览控制服务的受控状态目录,搜索接口需要控制台会话且结果有数量上限。
|
||||
- 预览 secrets 只从固定宿主路径读取,构建前校验 owner、类型和权限;不允许分支、Jenkins 参数或控制面请求改写 secrets 路径、BuildKit secret ID 或镜像内目标路径。
|
||||
- 预览 `.env.local` 与 secrets 只从固定宿主路径读取,构建前校验目录和文件的 owner、类型和权限;不允许分支、Jenkins 参数或控制面请求改写这些路径、BuildKit secret ID 或镜像内目标路径。
|
||||
- Jenkins POST 支持动态 Crumb;API Token 即使免 Crumb,也不能把 Token 放进 URL 或日志。
|
||||
- API 默认只接受同源请求,写请求校验 Origin;内网本身不作为认证。
|
||||
- 同一 deployment 的发布和卸载串行执行;重复请求必须幂等或明确返回冲突。
|
||||
|
||||
@@ -38,9 +38,9 @@ OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆
|
||||
生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用
|
||||
Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定
|
||||
OSS 前缀,通过 `AGC_RELEASE_VERSION` 指定三段版本号(仅在明确需要复现指定版本时使用),通过
|
||||
`AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。
|
||||
`AGC_UPDATE_RELEASE_NOTES` 写入发布说明,支持多行文本且保留内部换行;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。
|
||||
|
||||
每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本本身不负责上传 OSS,发布流水线通过 `release:upload` 完成上传。
|
||||
每次发布安装包上传完成后,再使用 ossutil 的 `--force` 覆盖上传同一目录生成的 `latest.json`,确保固定的 latest 指针和 `downloadUrl` 指向已存在的 OSS 对象;未显式强制覆盖时,ossutil 在目标已存在时会交互询问并按默认值跳过,不能作为 Jenkins 非交互发布方式。清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本本身不负责上传 OSS,发布流水线通过 `release:upload` 完成上传。
|
||||
|
||||
如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`,
|
||||
先按上述规则比较 OSS 版本、递增 patch、构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到
|
||||
@@ -56,7 +56,7 @@ Jenkins Agent 服务必须能在同一用户环境中找到这些命令。Tauri
|
||||
`tauri.windows.conf.json` 中的 `bundle.useLocalToolsDir: true`,把固定版本的 NSIS 工具缓存到
|
||||
`src-tauri/target/.tauri/NSIS`,不依赖 Jenkins 服务账户的 `%LOCALAPPDATA%\tauri` 或 PATH 中的系统 NSIS。
|
||||
Jenkins Checkout 的 `git clean -fdx` 会清理该构建目录,因此每次全新工作区可能重新下载 NSIS;这只影响构建耗时,不改变工具来源或执行权限要求。
|
||||
流水线执行根 workspace 的 `npm ci`,然后调用
|
||||
流水线参数 `AGC_UPDATE_RELEASE_NOTES` 使用 Jenkins `text` 类型,可直接输入多行发布说明;执行根 workspace 的 `npm ci`,然后调用
|
||||
`npm run ai-game-creator-shell:release:upload`,并归档 Windows 安装包、`latest.json` 与源码 commit。
|
||||
流水线会将未导出的空参数按空字符串处理:`COMMIT_HASH` 留空时沿用 Jenkins SCM 当前提交,`OSSUTIL_BIN` 留空时使用节点 PATH 中的 `ossutil`,不会因 PowerShell 对空环境变量调用 `.Trim()` 而提前失败。
|
||||
|
||||
|
||||
@@ -561,7 +561,7 @@ game-project/
|
||||
- 中间主视窗提供 `resource-overview / asset-canvas / resource-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生。静态图片继续进入 refine 素材创作无限画布,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流;底层 create 合同仅保留兼容。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;首次适配后仍接受内容宽高的真实变化,但仅 viewport 回灌或重复内容尺寸不更新 React 状态。容器 resize 后回到原生视口重新测量;放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
|
||||
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
|
||||
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
|
||||
- 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。
|
||||
|
||||
@@ -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
@@ -58,7 +58,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块
|
||||
|
||||
后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
|
||||
|
||||
AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
|
||||
AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
|
||||
|
||||
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID <pid> /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user