Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63a8e25f25 | |||
| 23b2a33257 | |||
| 016356e509 | |||
| 8dc0da4608 | |||
| e0d37edff2 | |||
| 6b4c6b8a63 | |||
| 8835311af3 | |||
| ecb0dc08b8 | |||
| 97bb0673bf | |||
| ba45ca7048 | |||
| f789db7f2f | |||
| 2ab849652e | |||
| 43b7aa809b | |||
| b62afb43b6 | |||
| 710d6dddf2 | |||
| 20b594d4f2 | |||
| 85f2831f16 | |||
| 1e186369c9 | |||
| 5c2f85c089 | |||
| 4380d453d7 | |||
| f502829fde | |||
| a56790eaa6 | |||
| 6e1fbac5d2 | |||
| b93d15e46e | |||
| 57a72f652c | |||
| 55801deb38 | |||
| a46302846a | |||
| 1eaa51b72b | |||
| 185649323e | |||
| 05a5ea4d85 | |||
| 3103a6e632 | |||
| b417144baf | |||
| fd0c1007ad |
@@ -30,8 +30,3 @@ server-rs/.data
|
||||
server-rs/.spacetimedb
|
||||
|
||||
public/generated-*
|
||||
|
||||
scripts/loadtest/data/*.local.json
|
||||
scripts/loadtest/data/k6-*.log
|
||||
scripts/loadtest/data/k6-*summary*.md
|
||||
scripts/loadtest/data/latest-*-prefix.txt
|
||||
|
||||
@@ -239,6 +239,12 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
||||
|
||||
# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。
|
||||
# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。
|
||||
# 本地端口按实际启动结果填写(端口漂移后需同步),localhost 与 127.0.0.1 不可混用。
|
||||
# dev 使用 https://dev.genarrative.world;release 使用 https://www.genarrative.world。
|
||||
GENARRATIVE_AGC_ANALYTICS_ORIGIN="http://127.0.0.1:8082"
|
||||
|
||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||
# the fixed `bge-large-zh` embedding model.
|
||||
|
||||
@@ -365,7 +365,6 @@ module.exports = {
|
||||
'src/types.ts',
|
||||
'src/types/**',
|
||||
'src/uiAssets.ts',
|
||||
'scripts/loadtest/**',
|
||||
'packages/shared/src/contracts/jumpHop.ts',
|
||||
'packages/shared/src/contracts/match3dAgent.ts',
|
||||
'packages/shared/src/contracts/match3dRuntime.ts',
|
||||
|
||||
@@ -88,11 +88,3 @@ nohup.out
|
||||
spacetime.local.json
|
||||
deploy/container/api-server.env
|
||||
deploy/container/worker-smoke/
|
||||
|
||||
# Local load-test data extracted from private migration files
|
||||
scripts/loadtest/data/*.local.json
|
||||
|
||||
# Local load-test run artifacts
|
||||
scripts/loadtest/data/k6-*.log
|
||||
scripts/loadtest/data/k6-*summary*.md
|
||||
scripts/loadtest/data/latest-*-prefix.txt
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
@@ -24,6 +25,30 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
|
||||
const payload = { entries: [], nextCursor: null };
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true, data: payload }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(
|
||||
await listAdminAgcTrackingEvents('token', {
|
||||
userId: 'user+1',
|
||||
projectId: 'project-1',
|
||||
cursor: 'page/2',
|
||||
limit: 50,
|
||||
}),
|
||||
).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/agc/tracking-events?userId=user%2B1&projectId=project-1&cursor=page%2F2&limit=50',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer token' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
||||
const library = { revision: 'revision-new', writable: true, templates: [] };
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminAgcTrackingEventListResponse,
|
||||
AdminAgcTrackingEventQuery,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
@@ -409,6 +411,31 @@ export function listAdminTrackingEventKeys(token: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminAgcTrackingEvents(
|
||||
token: string,
|
||||
query: AdminAgcTrackingEventQuery = {},
|
||||
) {
|
||||
return request<AdminAgcTrackingEventListResponse>(
|
||||
`/admin/api/agc/tracking-events${buildQueryString((params) => {
|
||||
for (const key of [
|
||||
'userId',
|
||||
'projectId',
|
||||
'creativeTaskId',
|
||||
'agentRunId',
|
||||
'eventName',
|
||||
'clientVersion',
|
||||
'startTime',
|
||||
'endTime',
|
||||
'cursor',
|
||||
] as const) {
|
||||
appendQueryParam(params, key, query[key]);
|
||||
}
|
||||
appendNumericQueryParam(params, 'limit', query.limit);
|
||||
})}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminErrorReports(
|
||||
token: string,
|
||||
query: {
|
||||
|
||||
@@ -820,6 +820,44 @@ export interface AdminTrackingEventListResponse {
|
||||
entries: AdminTrackingEventEntryPayload[];
|
||||
}
|
||||
|
||||
export interface AdminAgcTrackingEventQuery {
|
||||
userId?: string;
|
||||
projectId?: string;
|
||||
creativeTaskId?: string;
|
||||
agentRunId?: string;
|
||||
eventName?: string;
|
||||
clientVersion?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminAgcTrackingEventEntry {
|
||||
eventId: string;
|
||||
schemaVersion: number;
|
||||
eventName: string;
|
||||
eventTime: string;
|
||||
userId: string;
|
||||
editorSessionId: string;
|
||||
projectId: string | null;
|
||||
creativeTaskId: string | null;
|
||||
agentRunId: string | null;
|
||||
agentTurnId: string | null;
|
||||
status: string | null;
|
||||
errorCode: string | null;
|
||||
source: string;
|
||||
clientVersion: string;
|
||||
properties: Record<string, unknown>;
|
||||
batchId: string;
|
||||
receivedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminAgcTrackingEventListResponse {
|
||||
entries: AdminAgcTrackingEventEntry[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminTrackingEventKeyPayload {
|
||||
eventKey: string;
|
||||
eventTitle: string;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
||||
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
|
||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||
@@ -233,6 +234,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'agc-tracking' ? (
|
||||
<AdminAgcTrackingPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'error-reports' ? (
|
||||
<AdminErrorReportsPage
|
||||
token={token}
|
||||
|
||||
@@ -40,6 +40,7 @@ const routeIcons = {
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
'agc-tracking': Table2,
|
||||
'error-reports': Bug,
|
||||
'gray-release': GitBranch,
|
||||
redeem: TicketPercent,
|
||||
|
||||
@@ -8,6 +8,22 @@ import {
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
|
||||
test('客户端埋点路由遵守成员页签权限', () => {
|
||||
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['agc-tracking'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['agc-tracking']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['tracking'],
|
||||
}).some((route) => route.id === 'agc-tracking'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('后台默认进入 Dashboard', () => {
|
||||
expect(adminRoutes[0]).toEqual({
|
||||
id: 'dashboard',
|
||||
@@ -43,12 +59,6 @@ test('后台灰度发布路由可通过导航和 hash 访问', () => {
|
||||
expect(routeHash('gray-release')).toBe('#gray-release');
|
||||
});
|
||||
|
||||
test('后台不再暴露旧创作模板管理路由', () => {
|
||||
expect(resolveAdminRoute('#creation-entry')).toBe('dashboard');
|
||||
expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard');
|
||||
expect(resolveAdminRoute('#work-visibility')).toBe('dashboard');
|
||||
});
|
||||
|
||||
test('后台素材查询路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'editor-assets',
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AdminRouteId =
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
| 'tracking'
|
||||
| 'agc-tracking'
|
||||
| 'error-reports'
|
||||
| 'gray-release'
|
||||
| 'redeem'
|
||||
@@ -41,6 +42,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'tables', label: '表查询', hash: '#tables' },
|
||||
{ id: 'debug', label: 'API 调试', hash: '#debug' },
|
||||
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
|
||||
{ id: 'agc-tracking', label: '客户端埋点', hash: '#agc-tracking' },
|
||||
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
|
||||
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
|
||||
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AdminApiError,
|
||||
listAdminAgcTrackingEvents,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminAgcTrackingEventEntry } from '../api/adminApiTypes';
|
||||
import { AdminAgcTrackingPage } from './AdminAgcTrackingPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async (original) => ({
|
||||
...(await original<typeof import('../api/adminApiClient')>()),
|
||||
listAdminAgcTrackingEvents: vi.fn(),
|
||||
}));
|
||||
vi.mock('../components/AdminUserReferenceButton', () => ({
|
||||
AdminUserReferenceButton: () => null,
|
||||
}));
|
||||
|
||||
const entry: AdminAgcTrackingEventEntry = {
|
||||
eventId: 'event-1',
|
||||
schemaVersion: 1,
|
||||
eventName: 'project_save',
|
||||
eventTime: '2026-09-21T12:00:00.123Z',
|
||||
userId: 'user-1',
|
||||
editorSessionId: 'session-1',
|
||||
projectId: 'project-1',
|
||||
creativeTaskId: 'project-1',
|
||||
agentRunId: null,
|
||||
agentTurnId: null,
|
||||
status: 'success',
|
||||
errorCode: null,
|
||||
source: 'gui',
|
||||
clientVersion: '1.0',
|
||||
properties: { reason: 'manual' },
|
||||
batchId: 'batch-1',
|
||||
receivedAt: '2026-09-21T12:15:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminAgcTrackingEvents).mockReset();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
test('翻页沿用游标,筛选和刷新从第一页重新查询,详情保留 null 和事件属性', async () => {
|
||||
vi.mocked(listAdminAgcTrackingEvents)
|
||||
.mockResolvedValueOnce({ entries: [entry], nextCursor: 'cursor-page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
entries: [{ ...entry, eventId: 'event-2' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
.mockResolvedValue({ entries: [entry], nextCursor: 'cursor-new' });
|
||||
render(<AdminAgcTrackingPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('保存项目', { selector: 'td' });
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
|
||||
cursor: undefined,
|
||||
limit: 50,
|
||||
});
|
||||
fireEvent.click(screen.getByText('查看详情'));
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(within(dialog).getAllByText('—').length).toBe(3);
|
||||
expect(within(dialog).getByText(/"reason": "manual"/)).toBeTruthy();
|
||||
expect(within(dialog).getByText(entry.eventTime)).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByLabelText('关闭详情'));
|
||||
fireEvent.click(screen.getByText('下一页'));
|
||||
await waitFor(() =>
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
|
||||
cursor: 'cursor-page-2',
|
||||
limit: 50,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('查询').closest('button')?.disabled).toBe(false),
|
||||
);
|
||||
fireEvent.click(screen.getByText('上一页'));
|
||||
await screen.findByText('第 1 页');
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(2);
|
||||
fireEvent.change(screen.getByLabelText('项目 ID'), {
|
||||
target: { value: 'project-2' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('查询'));
|
||||
await waitFor(() =>
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
projectId: 'project-2',
|
||||
cursor: undefined,
|
||||
limit: 50,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('第 1 页')).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('刷新').closest('button')?.disabled).toBe(false),
|
||||
);
|
||||
fireEvent.click(screen.getByText('刷新'));
|
||||
await waitFor(() =>
|
||||
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(4),
|
||||
);
|
||||
});
|
||||
|
||||
test('空结果、权限失败与登录失效沿用后台反馈', async () => {
|
||||
const unauthorized = vi.fn();
|
||||
vi.mocked(listAdminAgcTrackingEvents)
|
||||
.mockResolvedValueOnce({ entries: [], nextCursor: null })
|
||||
.mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 403, message: '无权访问客户端埋点' }),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 401, message: '登录失效' }),
|
||||
);
|
||||
render(<AdminAgcTrackingPage token="token" onUnauthorized={unauthorized} />);
|
||||
await screen.findByText('暂无客户端埋点数据');
|
||||
fireEvent.click(screen.getByText('刷新'));
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'无权访问客户端埋点',
|
||||
);
|
||||
fireEvent.click(screen.getByText('刷新'));
|
||||
await waitFor(() =>
|
||||
expect(unauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { Modal } from '@genarrative/shared/components';
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminAgcTrackingEventEntry,
|
||||
AdminAgcTrackingEventListResponse,
|
||||
AdminAgcTrackingEventQuery,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
const eventLabels: Record<string, string> = {
|
||||
editor_session_start: '编辑器会话开始',
|
||||
editor_session_end: '编辑器会话结束',
|
||||
editor_focus_start: '编辑器获得焦点',
|
||||
editor_focus_end: '编辑器失去焦点',
|
||||
project_create_success: '项目创建成功',
|
||||
project_open: '打开项目',
|
||||
creative_task_submit: '首次提交创作目标',
|
||||
agent_run_completed: 'Agent 运行完成',
|
||||
agent_run_failed: 'Agent 运行失败',
|
||||
project_revision_created: '项目产生修改',
|
||||
preview_ready: '预览就绪',
|
||||
project_save: '保存项目',
|
||||
};
|
||||
|
||||
const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
|
||||
eventId: '事件 ID',
|
||||
schemaVersion: '事件版本',
|
||||
eventName: '事件类型',
|
||||
eventTime: '发生时间',
|
||||
userId: '用户 ID',
|
||||
editorSessionId: '编辑器会话 ID',
|
||||
projectId: '项目 ID',
|
||||
creativeTaskId: '创作目标 ID',
|
||||
agentRunId: 'Agent run ID',
|
||||
agentTurnId: 'Agent turn ID',
|
||||
status: '结果',
|
||||
errorCode: '错误码',
|
||||
source: '来源',
|
||||
clientVersion: '客户端版本',
|
||||
properties: '事件属性',
|
||||
batchId: '批次 ID',
|
||||
receivedAt: '入库时间',
|
||||
};
|
||||
|
||||
function formatTime(value: string) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function AdminAgcTrackingPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}) {
|
||||
const [filters, setFilters] = useState({
|
||||
userId: '',
|
||||
projectId: '',
|
||||
eventName: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
});
|
||||
const [query, setQuery] = useState<AdminAgcTrackingEventQuery>({});
|
||||
const [cursors, setCursors] = useState<Array<string | undefined>>([
|
||||
undefined,
|
||||
]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [entries, setEntries] = useState<AdminAgcTrackingEventEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<AdminAgcTrackingEventEntry | null>(null);
|
||||
const cursor = cursors[page];
|
||||
// 首页没有入站游标,返回首页时保留原快照,刷新才获取新数据。
|
||||
const firstPage = useRef<{
|
||||
token: string;
|
||||
query: AdminAgcTrackingEventQuery;
|
||||
response: AdminAgcTrackingEventListResponse;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = firstPage.current;
|
||||
if (!cursor && saved?.token === token && saved.query === query) {
|
||||
setEntries(saved.response.entries);
|
||||
setNextCursor(saved.response.nextCursor);
|
||||
setError('');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setEntries([]);
|
||||
setNextCursor(null);
|
||||
void listAdminAgcTrackingEvents(token, { ...query, cursor, limit: 50 })
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
if (!cursor) firstPage.current = { token, query, response };
|
||||
setEntries(response.entries);
|
||||
setNextCursor(response.nextCursor);
|
||||
})
|
||||
.catch((failure: unknown) => {
|
||||
if (active) handlePageError(failure, onUnauthorized, setError);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token, query, cursor, refresh, onUnauthorized]);
|
||||
|
||||
function resetPages() {
|
||||
firstPage.current = null;
|
||||
setCursors([undefined]);
|
||||
setPage(0);
|
||||
setDetail(null);
|
||||
}
|
||||
|
||||
function search(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
filters.startTime &&
|
||||
filters.endTime &&
|
||||
new Date(filters.startTime) >= new Date(filters.endTime)
|
||||
) {
|
||||
setError('结束时间必须晚于开始时间');
|
||||
return;
|
||||
}
|
||||
resetPages();
|
||||
setQuery({
|
||||
userId: filters.userId.trim(),
|
||||
projectId: filters.projectId.trim(),
|
||||
eventName: filters.eventName,
|
||||
startTime: filters.startTime
|
||||
? new Date(filters.startTime).toISOString()
|
||||
: undefined,
|
||||
endTime: filters.endTime
|
||||
? new Date(filters.endTime).toISOString()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function related(
|
||||
key: 'creativeTaskId' | 'agentRunId' | 'clientVersion',
|
||||
value: string,
|
||||
) {
|
||||
resetPages();
|
||||
setFilters({
|
||||
userId: '',
|
||||
projectId: '',
|
||||
eventName: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
});
|
||||
setQuery({ [key]: value });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>客户端埋点</h2>
|
||||
<p>按发生时间倒序展示</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
resetPages();
|
||||
setRefresh((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<form className="admin-panel admin-form" onSubmit={search}>
|
||||
<div className="admin-filter-grid">
|
||||
{(['userId', 'projectId'] as const).map((key) => (
|
||||
<label key={key} className="admin-field">
|
||||
<span>{fieldLabels[key]}</span>
|
||||
<input
|
||||
value={filters[key]}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, [key]: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="admin-field">
|
||||
<span>事件类型</span>
|
||||
<select
|
||||
value={filters.eventName}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, eventName: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{Object.entries(eventLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{(['startTime', 'endTime'] as const).map((key) => (
|
||||
<label key={key} className="admin-field">
|
||||
<span>
|
||||
{key === 'startTime'
|
||||
? '发生时间起点(含)'
|
||||
: '发生时间终点(不含)'}
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters[key]}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, [key]: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-primary-button"
|
||||
disabled={loading}
|
||||
>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
|
||||
.filter((key) => query[key])
|
||||
.map((key) => (
|
||||
<p key={key}>
|
||||
{fieldLabels[key]}:{query[key]}(重新查询可清除)
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
{error ? (
|
||||
<p role="alert" className="admin-error-message">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="admin-panel">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'入库时间',
|
||||
'发生时间',
|
||||
'用户',
|
||||
'事件名称',
|
||||
'项目',
|
||||
'来源',
|
||||
'结果',
|
||||
'客户端版本',
|
||||
'详情',
|
||||
].map((label) => (
|
||||
<th key={label}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.eventId}>
|
||||
<td>{formatTime(entry.receivedAt)}</td>
|
||||
<td>{formatTime(entry.eventTime)}</td>
|
||||
<td>
|
||||
{entry.userId}
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.userId}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</td>
|
||||
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
|
||||
<td>{entry.projectId ?? '—'}</td>
|
||||
<td>{entry.source}</td>
|
||||
<td>{entry.status ?? '—'}</td>
|
||||
<td>{entry.clientVersion}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-ghost-button"
|
||||
onClick={() => setDetail(entry)}
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p role="status">加载中…</p>
|
||||
) : !error && entries.length === 0 ? (
|
||||
<p>暂无客户端埋点数据</p>
|
||||
) : null}
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={loading || page === 0}
|
||||
onClick={() => setPage((value) => value - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>第 {page + 1} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-secondary-button"
|
||||
disabled={loading || !nextCursor}
|
||||
onClick={() => {
|
||||
if (!nextCursor) return;
|
||||
setCursors([...cursors.slice(0, page + 1), nextCursor]);
|
||||
setPage(page + 1);
|
||||
}}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{detail ? (
|
||||
<Modal
|
||||
open
|
||||
title="客户端埋点详情"
|
||||
closeLabel="关闭详情"
|
||||
onClose={() => setDetail(null)}
|
||||
className="genarrative-ui"
|
||||
>
|
||||
<dl>
|
||||
{(
|
||||
Object.keys(fieldLabels) as Array<
|
||||
keyof AdminAgcTrackingEventEntry
|
||||
>
|
||||
)
|
||||
.filter((key) => key !== 'properties')
|
||||
.map((key) => (
|
||||
<div key={key}>
|
||||
<dt>{fieldLabels[key]}</dt>
|
||||
<dd style={{ overflowWrap: 'anywhere' }}>
|
||||
{detail[key] == null ? '—' : String(detail[key])}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="admin-action-row">
|
||||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
|
||||
(key) =>
|
||||
detail[key] ? (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
className="admin-secondary-button"
|
||||
onClick={() => related(key, detail[key]!)}
|
||||
>
|
||||
查询同一{fieldLabels[key]}
|
||||
</button>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<h3>事件属性</h3>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
{JSON.stringify(detail.properties, null, 2)}
|
||||
</pre>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2641,48 +2641,6 @@ const databaseTableLabelMap: Record<string, string> = {
|
||||
profile_recharge_order: '充值订单',
|
||||
profile_feedback_submission: '反馈提交',
|
||||
profile_save_archive: '存档记录',
|
||||
story_session: '剧情会话',
|
||||
story_event: '剧情事件',
|
||||
npc_state: 'NPC 状态',
|
||||
inventory_slot: '背包槽位',
|
||||
battle_state: '战斗状态',
|
||||
treasure_record: '宝藏记录',
|
||||
quest_record: '任务记录',
|
||||
quest_log: '任务日志',
|
||||
player_progression: '玩家进度',
|
||||
chapter_progression: '章节进度',
|
||||
custom_world_profile: '自定义世界档案',
|
||||
custom_world_session: '自定义世界会话',
|
||||
custom_world_agent_session: '自定义世界 Agent 会话',
|
||||
custom_world_agent_message: '自定义世界 Agent 消息',
|
||||
custom_world_agent_operation: '自定义世界 Agent 操作',
|
||||
custom_world_draft_card: '自定义世界草稿卡片',
|
||||
custom_world_gallery_entry: '自定义世界画廊条目',
|
||||
puzzle_agent_session: '拼图 Agent 会话',
|
||||
puzzle_agent_message: '拼图 Agent 消息',
|
||||
puzzle_work_profile: '拼图作品档案',
|
||||
puzzle_event: '拼图事件',
|
||||
puzzle_runtime_run: '拼图运行记录',
|
||||
puzzle_leaderboard_entry: '拼图排行榜条目',
|
||||
match3d_agent_session: '抓大鹅 Agent 会话',
|
||||
match3d_agent_message: '抓大鹅 Agent 消息',
|
||||
match3d_work_profile: '抓大鹅作品档案',
|
||||
match3d_runtime_run: '抓大鹅运行记录',
|
||||
square_hole_agent_session: '方洞挑战 Agent 会话',
|
||||
square_hole_agent_message: '方洞挑战 Agent 消息',
|
||||
square_hole_work_profile: '方洞挑战作品档案',
|
||||
square_hole_runtime_run: '方洞挑战运行记录',
|
||||
visual_novel_agent_session: '视觉小说 Agent 会话',
|
||||
visual_novel_agent_message: '视觉小说 Agent 消息',
|
||||
visual_novel_work_profile: '视觉小说作品档案',
|
||||
visual_novel_runtime_run: '视觉小说运行记录',
|
||||
visual_novel_runtime_history_entry: '视觉小说历史条目',
|
||||
visual_novel_runtime_event: '视觉小说运行事件',
|
||||
big_fish_creation_session: '大鱼吃小鱼创建会话',
|
||||
big_fish_agent_message: '大鱼吃小鱼 Agent 消息',
|
||||
big_fish_asset_slot: '大鱼吃小鱼资产槽位',
|
||||
big_fish_event: '大鱼吃小鱼事件',
|
||||
big_fish_runtime_run: '大鱼吃小鱼运行记录',
|
||||
asset_object: '资产对象',
|
||||
asset_entity_binding: '资产实体绑定',
|
||||
asset_event: '资产事件',
|
||||
@@ -2724,48 +2682,6 @@ const databaseTableDescriptionMap: Record<string, string> = {
|
||||
profile_recharge_order: '充值订单表',
|
||||
profile_feedback_submission: '反馈提交记录表',
|
||||
profile_save_archive: '用户存档记录表',
|
||||
story_session: '剧情会话表',
|
||||
story_event: '剧情事件表',
|
||||
npc_state: 'NPC 状态表',
|
||||
inventory_slot: '背包槽位表',
|
||||
battle_state: '战斗状态表',
|
||||
treasure_record: '宝藏记录表',
|
||||
quest_record: '任务记录表',
|
||||
quest_log: '任务日志表',
|
||||
player_progression: '玩家进度表',
|
||||
chapter_progression: '章节进度表',
|
||||
custom_world_profile: '自定义世界档案表',
|
||||
custom_world_session: '自定义世界会话表',
|
||||
custom_world_agent_session: '自定义世界 Agent 会话表',
|
||||
custom_world_agent_message: '自定义世界 Agent 消息表',
|
||||
custom_world_agent_operation: '自定义世界 Agent 操作表',
|
||||
custom_world_draft_card: '自定义世界草稿卡片表',
|
||||
custom_world_gallery_entry: '自定义世界画廊条目表',
|
||||
puzzle_agent_session: '拼图 Agent 会话表',
|
||||
puzzle_agent_message: '拼图 Agent 消息表',
|
||||
puzzle_work_profile: '拼图作品档案表',
|
||||
puzzle_event: '拼图事件表',
|
||||
puzzle_runtime_run: '拼图运行记录表',
|
||||
puzzle_leaderboard_entry: '拼图排行榜条目表',
|
||||
match3d_agent_session: '抓大鹅 Agent 会话表',
|
||||
match3d_agent_message: '抓大鹅 Agent 消息表',
|
||||
match3d_work_profile: '抓大鹅作品档案表',
|
||||
match3d_runtime_run: '抓大鹅运行记录表',
|
||||
square_hole_agent_session: '方洞挑战 Agent 会话表',
|
||||
square_hole_agent_message: '方洞挑战 Agent 消息表',
|
||||
square_hole_work_profile: '方洞挑战作品档案表',
|
||||
square_hole_runtime_run: '方洞挑战运行记录表',
|
||||
visual_novel_agent_session: '视觉小说 Agent 会话表',
|
||||
visual_novel_agent_message: '视觉小说 Agent 消息表',
|
||||
visual_novel_work_profile: '视觉小说作品档案表',
|
||||
visual_novel_runtime_run: '视觉小说运行记录表',
|
||||
visual_novel_runtime_history_entry: '视觉小说历史条目表',
|
||||
visual_novel_runtime_event: '视觉小说运行事件表',
|
||||
big_fish_creation_session: '大鱼吃小鱼创建会话表',
|
||||
big_fish_agent_message: '大鱼吃小鱼 Agent 消息表',
|
||||
big_fish_asset_slot: '大鱼吃小鱼资产槽位表',
|
||||
big_fish_event: '大鱼吃小鱼事件表',
|
||||
big_fish_runtime_run: '大鱼吃小鱼运行记录表',
|
||||
asset_object: '资产对象表',
|
||||
asset_entity_binding: '资产实体绑定表',
|
||||
asset_event: '资产事件表',
|
||||
|
||||
@@ -255,6 +255,27 @@ test('灰度发布页保存游戏发布开关时写入白名单与比例', async
|
||||
);
|
||||
});
|
||||
|
||||
test('未创建的预设开关在后台可见并可一键配置', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const row = await screen.findByText('game-distribution:publish');
|
||||
expect(row).not.toBeNull();
|
||||
// 该开关默认未创建:列表里给出「配置」入口,点击后按默认关闭填充表单。
|
||||
const configureButton = row.closest('tr')?.querySelector('button');
|
||||
expect(configureButton).not.toBeNull();
|
||||
await user.click(configureButton!);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'game-distribution:publish',
|
||||
);
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
|
||||
@@ -206,6 +206,11 @@ export function AdminGrayReleaseConfigPage({
|
||||
setErrorMessage('');
|
||||
}
|
||||
|
||||
// 预设里尚未创建行的开关也要可见:运营需要先看到 key 才能配置灰度。
|
||||
const unconfiguredGateTargets = FIXED_GATE_TARGETS.filter(
|
||||
(option) => !gates.some((gate) => gate.gateKey === option.key),
|
||||
);
|
||||
|
||||
function buildPayload(): AdminUpsertFeatureGateConfigRequest {
|
||||
return {
|
||||
gateKey: gateKey.trim(),
|
||||
@@ -452,6 +457,53 @@ export function AdminGrayReleaseConfigPage({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>可配置开关</h3>
|
||||
<span>{unconfiguredGateTargets.length}</span>
|
||||
</div>
|
||||
{unconfiguredGateTargets.length ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Gate</th>
|
||||
<th>说明</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unconfiguredGateTargets.map((option) => (
|
||||
<tr key={option.key}>
|
||||
<td>
|
||||
{option.key}
|
||||
<small>
|
||||
{GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '}
|
||||
{option.label}
|
||||
</small>
|
||||
</td>
|
||||
<td>{option.description}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-text-button"
|
||||
type="button"
|
||||
onClick={() => applyGateTarget(option)}
|
||||
>
|
||||
配置
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-empty-state">
|
||||
{isLoading ? '加载中' : '预设开关都已创建'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{confirmDialog}
|
||||
|
||||
@@ -154,7 +154,6 @@ const allowedUncalledTauriCommands = [
|
||||
'steer_game_creator_agent_runtime_task',
|
||||
'write_local_agent_memory',
|
||||
'write_local_game_memory',
|
||||
'write_local_project_file',
|
||||
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
|
||||
'archive_game_creator_agent_session',
|
||||
'clear_game_creator_agent_goal',
|
||||
@@ -517,7 +516,7 @@ function parseTauriHandlerCommandNames(source) {
|
||||
throw new Error('AI game creator shell Tauri handler list is missing');
|
||||
}
|
||||
return Array.from(
|
||||
match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g),
|
||||
match[1].matchAll(/\b(?:[a-z][a-z0-9_]*::)*([a-z][a-z0-9_]*)\b/g),
|
||||
([, command]) => command,
|
||||
);
|
||||
}
|
||||
@@ -550,6 +549,12 @@ function assertCommandNamesDisjoint(label, leftNames, rightNames) {
|
||||
}
|
||||
|
||||
function runAppInvokeParserRegressionChecks() {
|
||||
assert.deepEqual(
|
||||
parseTauriHandlerCommandNames(
|
||||
'tauri::generate_handler![plain_command, analytics::gui::capture_analytics_context,]',
|
||||
),
|
||||
['plain_command', 'capture_analytics_context'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseAppInvokeCommandNames(`
|
||||
invoke('direct_command', {});
|
||||
|
||||
@@ -125,17 +125,24 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
|
||||
&& sha256_file(&target_path)
|
||||
.map(|target_sha256| target_sha256 == source_sha256)
|
||||
.unwrap_or(false);
|
||||
let source_permissions = fs::metadata(&source_path)
|
||||
.expect("读取组件权限失败")
|
||||
.permissions();
|
||||
if !target_matches_source {
|
||||
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
|
||||
fs::set_permissions(&target_path, source_permissions.clone())
|
||||
.expect("保留内置 Codex CLI 组件权限失败");
|
||||
} else if fs::metadata(&target_path)
|
||||
.expect("读取内置 Codex CLI 资源失败")
|
||||
.permissions()
|
||||
!= source_permissions
|
||||
{
|
||||
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
|
||||
// 权限已一致时不再写元数据:Windows 上这次写入会更新 change time,
|
||||
// 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。
|
||||
fs::set_permissions(&target_path, source_permissions)
|
||||
.expect("保留内置 Codex CLI 组件权限失败");
|
||||
}
|
||||
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
|
||||
fs::set_permissions(
|
||||
&target_path,
|
||||
fs::metadata(&source_path)
|
||||
.expect("读取组件权限失败")
|
||||
.permissions(),
|
||||
)
|
||||
.expect("保留内置 Codex CLI 组件权限失败");
|
||||
file_hashes.insert(
|
||||
relative.to_string(),
|
||||
serde_json::Value::String(source_sha256),
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
- project/决策台账.md
|
||||
- project/dialog.md
|
||||
正式产物使用当前阶段指定的相对路径。
|
||||
五个策划阶段的审批:当你判断当前策划阶段必需产物已完成时,必须提交阶段审批。用户批准后 Runtime 自动进入下一阶段。
|
||||
五个策划阶段的审批:当你判断当前策划阶段必需产物已完成时,必须提交阶段审批。用户批准后进入下一阶段。
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -59,8 +59,8 @@
|
||||
|---|---|---|---|---|---|---|
|
||||
| D-08 | 作物品质两档:普通/银 | TDD | agent | 早期小权衡:两档最简 | — | **overturned → D-18**(经济分层不足,改三档;铱档留 P1) |
|
||||
|
||||
## 队列纪律(给 agent 的使用说明)
|
||||
## 队列纪律(使用说明)
|
||||
|
||||
- 新决定入队:拿下一号(当前最大 D-19,下一号 D-20);就现代决可登记不开条目,但状态必须写 auto_decided 并带理由+推翻条件。
|
||||
- 用户翻案:旧行标 overturned 挂新行,受影响文档节重写(本台账只记录,不代改)。
|
||||
- 概念层变更定稿后:速览卡"决定状态与原型验证项"字段随本文件最新版同步(prompt 级纪律)。
|
||||
- 概念层变更定稿后:速览卡"决定状态与原型验证项"字段随本文件最新版同步。
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
# 脉冲荒原:节点争夺战
|
||||
|
||||
> Fast GDD v1 · 状态:ready\_for\_approval
|
||||
> gddId:`gdd-a5309290-edd4-4232-9c7c-d99024e0fa42`
|
||||
> fingerprint:`sha256-serde-json-v2:1d34b0cbee5f623a224102e4d832ad6ecc10ffbf27b876d9bc1e24a5483f06a3`
|
||||
> approvalRequestId:`gdd-approval-6f5de673-553d-499f-8ca1-8c32f4841bc0`
|
||||
|
||||
## 决定状态
|
||||
|
||||
@@ -64,12 +61,12 @@
|
||||
- 单局时长:默认每局约10~20分钟;首个可玩闭环优先做到一局可完整开始、争夺、决胜和重开。
|
||||
- 参考游戏:无
|
||||
|
||||
## Runtime 平台事实
|
||||
## 平台事实
|
||||
|
||||
- Runtime:self-contained-web
|
||||
- 视口:desktop / mobile
|
||||
- 输入:keyboard / touch
|
||||
- 预览:local-http
|
||||
- 运行方式:浏览器内运行,离线可用
|
||||
- 视口:桌面 / 移动
|
||||
- 输入:键盘 / 触控
|
||||
- 预览:本地启动后可在浏览器中试玩
|
||||
|
||||
## MVP 系统
|
||||
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# 速览卡:《星露谷物语》金样项目
|
||||
|
||||
> 12 字段版本;渲染器照旧出卡走审批。字段来源见每节尾注(概念层/定调记录/台账投影)。概念层变更定稿后本卡必须同步更新(prompt 级纪律)。
|
||||
> 字段来源见每节尾注(概念层/定调记录/决策台账)。概念层变更定稿后本卡必须同步更新。
|
||||
|
||||
## 1. 游戏名称
|
||||
《星露谷物语》(金样项目沿用案例名;新项目由概念层第 1 节定名)←概念层§1
|
||||
@@ -30,8 +30,8 @@
|
||||
## 7. 目标用户 ←概念层§5
|
||||
牧场物语系慢节奏成长玩家+动森式"无压力日常"需求;单人、可反复、每次一至数个游戏日;不要求预先掌握复杂数值。
|
||||
|
||||
## 8. 平台事实 ←运行时注入,禁改
|
||||
自包含 Web · 双视口(桌面/移动)· 键鼠/触屏双输入 · 本地 HTTP 预览(金样项目按 HTML 演示路线锁定)。
|
||||
## 8. 平台事实(禁改)
|
||||
Web 浏览器运行 · 双视口(桌面/移动)· 键鼠/触屏双输入 · 本地启动后可在浏览器中试玩。
|
||||
|
||||
## 9. MVP 系统(5 个)←概念层"最小闭环粗清单"
|
||||
| 系统 | 最小功能 | 为什么必须有 | 验证方法 |
|
||||
@@ -51,7 +51,7 @@
|
||||
- 这样验证:测试者玩完第 1 日后是否主动说"再玩一天";能否说出"明天要先做什么"。
|
||||
- 达标再扩展:玩家能自述明日计划后,才加社交深度与矿井分层。
|
||||
|
||||
## 12. 决定状态与原型验证项 ←台账投影(decision-log.md v3)
|
||||
## 12. 决定状态与原型验证项(依据决策台账)
|
||||
- 已确认(confirmed):定调 D-01 / 单人 D-02 / 战斗伴生 D-03 / 日目标自设 D-07 / 活动统一接口 D-11 / 战斗节奏判定 D-13。
|
||||
- 技术代决(auto_decided,可一键翻案):D-04 时间片 / D-05 分区域切换 / D-06 28 日季 / D-09 营业条件 / D-10 出货箱日终 / D-12 工具占用 / D-16 矿井生成 P2 / D-17 换装 5 层 / D-18 品质三档(推翻 D-08 两档)。
|
||||
- 等拍板(pending_user):D-14 体力战斗是否共享单池(暂按共享实现);D-15 背包格子/重量(暂按格子制,B 级阻断存档结构)。
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@
|
||||
| BGM | ogg,-18LUFS,循环点标记 | `bgm_{season}` 4 首+矿井 1 首 | — | 响度+循环 | 豁免(音频契约) |
|
||||
| SFX | wav 单发 | `sfx_{event}`(事件↔音效映射表登记) | — | 同帧触发 | 豁免(音频契约) |
|
||||
|
||||
- 绘制工艺:陶泥儿 MCP 像素绘制路径逐类写参数(作物/图标/角色三类的封装流程);施工环境无产出通道时规格先行锁定、状态如实登记"缺失"。
|
||||
- 绘制工艺:按项目实际制作路径逐类记录参数与封装流程;施工环境无产出通道时规格先行锁定、状态如实登记"缺失"。
|
||||
- 音频契约说明:原作 XACT cue 名 435 候选/代码引用 230 个——本项目首期 SFX 事件 20 只起步,按事件总线 `sfx_event` 映射表登记,不逐 cue 复刻。
|
||||
- 豁免类型仅限:程序化生成(矿井布局由表驱动拼装)/ UI 文本 / 本期不需要——每项豁免在契约行写明。
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
## 自足性检查(2026-09-06 生产态复评)
|
||||
|
||||
| # | 施工 agent 的问题 | 答案在哪 | 状态 |
|
||||
| # | 施工方的问题 | 答案在哪 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 1 | 七个 P0 系统怎么行为? | 01 收编章(P0 七系统规则全文已收编@v1;S06 P1 要点已收) | **过** |
|
||||
| 2 | 表里有多少行内容、文本全填了吗? | 03 全量填充(作物8/敌人3/NPC12/文本40/物品46/配方14 全填,第八查全绿) | **过** |
|
||||
@@ -13,7 +13,7 @@
|
||||
| 5 | 代码怎么组织、跑在哪? | 01 代码组织+能力边界(三态全落位) | **过** |
|
||||
| 6 | 怎么算做完? | 01 里程碑三判据+三件验收 | **过** |
|
||||
|
||||
**结论:六问全过——TDD 规格已自足,施工 agent 可只凭本 TDD 开工。**
|
||||
**结论:六问全过——TDD 规格已自足,施工方 可只凭本 TDD 开工。**
|
||||
剩余非规格缺口(不阻塞开工,按里程碑推进):①P1 四系统(S05/S10/S11/S12)施工前补文档并收编;②资产表 30 行量产(按十步流程排期);③B 级两项(背包容量、生命体力共享)在 v0.1 存档实现前收口。
|
||||
|
||||
## 三件状态
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user