8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出 - AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表) - AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序 - AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它 - AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间 - 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列 - 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable - 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心 - 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染 - docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑 - 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
1119 lines
31 KiB
JavaScript
1119 lines
31 KiB
JavaScript
// 临时脚本:给本地后台灌入假数据的假 API(仅用于人工查看渲染效果与截图)。
|
|
// 用法:node scripts/tmp-admin-fake-api.mjs [port]
|
|
import http from 'node:http';
|
|
|
|
const port = Number(process.argv[2] ?? 8099);
|
|
|
|
const admin = {
|
|
subject: 'admin:local',
|
|
username: 'owner',
|
|
displayName: '本地体验账号',
|
|
roles: ['admin', 'owner'],
|
|
accountRole: 'owner',
|
|
tabPermissions: [],
|
|
actionPermissions: [],
|
|
issuedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
|
};
|
|
|
|
const avatar = (text, background) =>
|
|
`data:image/svg+xml;utf8,${encodeURIComponent(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96"><rect width="96" height="96" rx="20" fill="${background}"/><text x="48" y="60" font-size="40" font-family="sans-serif" text-anchor="middle" fill="#fff">${text}</text></svg>`,
|
|
)}`;
|
|
|
|
const cover = (label, background) =>
|
|
`data:image/svg+xml;utf8,${encodeURIComponent(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="320" height="200" fill="${background}"/><text x="160" y="112" font-size="28" font-family="sans-serif" text-anchor="middle" fill="#fff">${label}</text></svg>`,
|
|
)}`;
|
|
|
|
const now = Date.now();
|
|
const iso = (offsetMinutes) =>
|
|
new Date(now - offsetMinutes * 60_000).toISOString();
|
|
|
|
/** 素材查询列表里的假素材。 */
|
|
function editorAsset(assetId, label, ownerUserId, authorDisplayName, code) {
|
|
return {
|
|
assetId,
|
|
ownerUserId,
|
|
authorDisplayName,
|
|
authorPublicUserCode: code,
|
|
folderId: 'folder_local',
|
|
label,
|
|
assetObjectId: `${assetId}-object`,
|
|
imageSrc: cover(label, '#b6623f'),
|
|
objectKey: `assets/${assetId}.png`,
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated',
|
|
prompt: 'a warm clay-style illustration',
|
|
actualPrompt: 'a warm clay-style illustration, soft light',
|
|
model: 'seedream-4',
|
|
provider: 'vectorengine',
|
|
taskId: `task_${assetId}`,
|
|
groupTaskId: null,
|
|
assetKind: 'image',
|
|
generationInputs: { size: '1024x1024' },
|
|
sourceResourceId: null,
|
|
sourceImageSrc: null,
|
|
generationCostMudPoints: 12,
|
|
refundMudPoints: 0,
|
|
createdAt: iso(60),
|
|
updatedAt: iso(30),
|
|
};
|
|
}
|
|
|
|
/** 精选审核列表里的假素材。 */
|
|
function showcaseAsset(
|
|
showcaseId,
|
|
label,
|
|
ownerUserId,
|
|
authorDisplayName,
|
|
code,
|
|
) {
|
|
return {
|
|
showcaseId,
|
|
assetId: `${showcaseId}_asset`,
|
|
ownerUserId,
|
|
authorDisplayName,
|
|
authorPublicUserCode: code,
|
|
label,
|
|
imageSrc: cover(label, '#8f4b26'),
|
|
objectKey: `showcase/${showcaseId}.png`,
|
|
width: 1024,
|
|
height: 1536,
|
|
prompt: 'steampunk town at dusk',
|
|
actualPrompt: 'steampunk town at dusk, warm clay palette',
|
|
model: 'seedream-4',
|
|
provider: 'vectorengine',
|
|
taskId: `task_${showcaseId}`,
|
|
sourceResourceId: null,
|
|
sourceImageSrc: null,
|
|
sourceObjectKey: null,
|
|
sourceAssetObjectId: null,
|
|
sourceLabel: null,
|
|
assetKind: 'image',
|
|
generationInputs: { size: '1024x1536' },
|
|
thumbnailSrc: null,
|
|
generationCostMudPoints: 16,
|
|
refundMudPoints: 0,
|
|
reviewStatus: 'pending',
|
|
displayEnabled: false,
|
|
likeCount: 12,
|
|
assetDeletedWhilePending: false,
|
|
reviewedByAdminUserId: null,
|
|
reviewNote: null,
|
|
refundLedgerId: null,
|
|
refundCompletedAt: null,
|
|
submittedAt: iso(45),
|
|
};
|
|
}
|
|
|
|
const tableStats = [
|
|
['account_profile', 1284, null],
|
|
['editor_asset', 8632, null],
|
|
['project_snapshot', 412, null],
|
|
['tracking_event', 92_431, null],
|
|
['wallet_ledger', 5281, null],
|
|
['ip_blacklist', null, '不可统计(private 或当前身份不可见)'],
|
|
];
|
|
|
|
const trackingEntries = [
|
|
{
|
|
eventId: 'evt_01HQ8Z3K7M4N2P',
|
|
eventKey: 'daily_login',
|
|
eventTitle: '每日登录',
|
|
scopeKind: 'user',
|
|
scopeId: 'u_1001',
|
|
dayKey: 20260929,
|
|
userId: 'u_1001',
|
|
ownerUserId: null,
|
|
profileId: 'p_1001',
|
|
moduleKey: 'profile',
|
|
metadataJson: '{"channel":"wechat","days":3}',
|
|
occurredAt: iso(12),
|
|
},
|
|
{
|
|
eventId: 'evt_01HQ8Z3K7M4N3Q',
|
|
eventKey: 'editor_generate_image',
|
|
eventTitle: '画板生成图片',
|
|
scopeKind: 'project',
|
|
scopeId: 'proj_7788',
|
|
dayKey: 20260929,
|
|
userId: 'u_1002',
|
|
// 事件用户与项目归属人通常不是同一个人,假数据按这个口径给。
|
|
ownerUserId: 'u_1005',
|
|
profileId: null,
|
|
moduleKey: 'editor',
|
|
metadataJson:
|
|
'{"model":"seedream-4","size":"2048x2048","cost":12,"ok":true,"seed":null}',
|
|
occurredAt: iso(48),
|
|
},
|
|
{
|
|
eventId: 'evt_01HQ8Z3K7M4N4R',
|
|
eventKey: 'agc_project_publish',
|
|
eventTitle: '发布独立 App 游戏',
|
|
scopeKind: 'game',
|
|
scopeId: 'game_9001',
|
|
dayKey: 20260928,
|
|
userId: 'u_1003',
|
|
ownerUserId: null,
|
|
profileId: null,
|
|
moduleKey: 'ai-game-creator',
|
|
metadataJson: '{"version":7,"platform":"windows"}',
|
|
occurredAt: iso(600),
|
|
},
|
|
{
|
|
// 作者在自己项目里生成,事件用户与归属人相同:不该出现第二个重复按钮。
|
|
eventId: 'evt_01HQ8Z3K7M4N5S',
|
|
eventKey: 'editor_generate_video',
|
|
eventTitle: '画板生成视频',
|
|
scopeKind: 'project',
|
|
scopeId: 'proj_9001',
|
|
dayKey: 20260928,
|
|
userId: 'u_1003',
|
|
ownerUserId: 'u_1003',
|
|
profileId: 'p_1003',
|
|
moduleKey: 'editor',
|
|
metadataJson: '{"model":"video-1","seconds":5,"cost":40}',
|
|
occurredAt: iso(900),
|
|
},
|
|
];
|
|
|
|
const inviteEntries = [
|
|
{
|
|
userId: 'u_8801',
|
|
inviteCode: 'TAONIER-VIP-2026',
|
|
metadata: { userTags: ['vip', '内测'] },
|
|
startsAt: iso(60 * 24 * 7),
|
|
expiresAt: null,
|
|
status: 'active',
|
|
createdAt: iso(60 * 24 * 7),
|
|
updatedAt: iso(60 * 24 * 2),
|
|
},
|
|
{
|
|
userId: 'u_8802',
|
|
inviteCode: 'BETA-CREATOR',
|
|
metadata: { userTags: ['creator'] },
|
|
startsAt: null,
|
|
expiresAt: iso(-60 * 24 * 3),
|
|
status: 'active',
|
|
createdAt: iso(60 * 24 * 20),
|
|
updatedAt: iso(60 * 24 * 19),
|
|
},
|
|
{
|
|
userId: 'u_8803',
|
|
inviteCode: 'EXPIRED-CODE',
|
|
metadata: {},
|
|
startsAt: iso(60 * 24 * 40),
|
|
expiresAt: iso(60 * 24 * 10),
|
|
status: 'expired',
|
|
createdAt: iso(60 * 24 * 40),
|
|
updatedAt: iso(60 * 24 * 10),
|
|
},
|
|
];
|
|
|
|
const inviteOperations = [
|
|
{
|
|
operationId: 'op_1',
|
|
codeKind: 'invite',
|
|
code: 'TAONIER-VIP-2026',
|
|
action: 'create',
|
|
operatorUserId: 'u_admin',
|
|
operatorDisplayName: '本地体验账号',
|
|
createdAt: iso(60 * 24 * 7),
|
|
},
|
|
{
|
|
operationId: 'op_2',
|
|
codeKind: 'invite',
|
|
code: 'BETA-CREATOR',
|
|
action: 'update',
|
|
operatorUserId: 'u_admin',
|
|
operatorDisplayName: '本地体验账号',
|
|
createdAt: iso(60 * 24 * 19),
|
|
},
|
|
];
|
|
|
|
const gates = [
|
|
{
|
|
gateKey: 'agc:template-library',
|
|
enabled: true,
|
|
rolloutPercent: 40,
|
|
allowUserIds: ['u_1001', 'u_1002'],
|
|
allowUserTags: ['vip'],
|
|
denyUserIds: [],
|
|
description: 'AGC 模板库灰度',
|
|
updatedAt: iso(120),
|
|
},
|
|
{
|
|
gateKey: 'editor:showcase',
|
|
enabled: false,
|
|
rolloutPercent: 0,
|
|
allowUserIds: [],
|
|
allowUserTags: [],
|
|
denyUserIds: ['u_9001'],
|
|
description: '精选作品投放开关',
|
|
updatedAt: iso(60 * 30),
|
|
},
|
|
];
|
|
|
|
const snapshotItems = [
|
|
{
|
|
userId: 'u_1001',
|
|
projectId: 'proj_7788',
|
|
projectName: '陶泥小世界',
|
|
syncRevision: 12,
|
|
syncedAtMs: now - 1000 * 60 * 26,
|
|
fileCount: 184,
|
|
totalBytes: 24_318_712,
|
|
status: 'ready',
|
|
channel: 'dev',
|
|
authorDisplayName: '阿陶',
|
|
authorPublicUserCode: 'TN-1001',
|
|
},
|
|
{
|
|
userId: 'u_1002',
|
|
projectId: 'proj_8100',
|
|
projectName: '纸片拼图计划',
|
|
syncRevision: 3,
|
|
syncedAtMs: now - 1000 * 60 * 180,
|
|
fileCount: 59,
|
|
totalBytes: 5_120_004,
|
|
status: 'partial',
|
|
channel: 'dev',
|
|
authorDisplayName: '小满',
|
|
authorPublicUserCode: 'TN-1002',
|
|
},
|
|
{
|
|
userId: 'u_1003',
|
|
projectId: 'proj_4412',
|
|
projectName: '三消工坊',
|
|
syncRevision: 27,
|
|
syncedAtMs: now - 1000 * 60 * 60 * 30,
|
|
fileCount: 402,
|
|
totalBytes: 88_120_330,
|
|
status: 'unverified',
|
|
channel: 'dev',
|
|
authorDisplayName: '老周',
|
|
authorPublicUserCode: 'TN-1003',
|
|
},
|
|
];
|
|
|
|
const errorReports = [
|
|
{
|
|
batchId: 'batch_20260929_01',
|
|
eventCount: 6,
|
|
logCount: 2,
|
|
fingerprint: 'fp_9f2a',
|
|
source: 'agc-desktop',
|
|
status: 'new',
|
|
userId: 'u_1002',
|
|
createdAt: iso(45),
|
|
updatedAt: iso(45),
|
|
},
|
|
{
|
|
batchId: 'batch_20260928_07',
|
|
eventCount: 3,
|
|
logCount: 1,
|
|
fingerprint: 'fp_4b71',
|
|
source: 'web',
|
|
status: 'in-progress',
|
|
userId: 'u_1001',
|
|
createdAt: iso(60 * 20),
|
|
updatedAt: iso(60 * 4),
|
|
},
|
|
{
|
|
batchId: 'batch_20260927_02',
|
|
eventCount: 11,
|
|
logCount: 4,
|
|
fingerprint: 'fp_1c05',
|
|
source: 'agc-mobile',
|
|
status: 'resolved',
|
|
userId: 'u_1004',
|
|
createdAt: iso(60 * 40),
|
|
updatedAt: iso(60 * 36),
|
|
},
|
|
];
|
|
|
|
const errorReportDetail = {
|
|
...errorReports[0],
|
|
firstFingerprint: 'fp_9f2a',
|
|
firstSource: 'agc-desktop',
|
|
note: '已定位到随包 Codex 版本过旧,等待渠道重新发布。',
|
|
events: [
|
|
{
|
|
level: 'error',
|
|
message: 'Codex app-server JSON-RPC 失败:items must not be empty',
|
|
},
|
|
{ level: 'warn', message: 'provider request retried once' },
|
|
],
|
|
logNames: ['agc-main.log', 'agc-provider.log'],
|
|
};
|
|
|
|
const dashboard = {
|
|
range: {
|
|
granularity: 'day',
|
|
anchorDate: '2026-09-29',
|
|
periodStartDate: '2026-09-23',
|
|
periodEndDate: '2026-09-29',
|
|
periodLabel: '2026-09-23 ~ 2026-09-29',
|
|
},
|
|
metrics: {
|
|
generatedAssets: 438,
|
|
consumedMudPoints: 96_400,
|
|
refundedMudPoints: 3_120,
|
|
totalRegisteredUsers: 12_480,
|
|
newRegisteredUsers: 128,
|
|
newUserPaymentConversion: {
|
|
paidUsers: 21,
|
|
newRegisteredUsers: 128,
|
|
rateBasisPoints: 1_641,
|
|
},
|
|
day1Retention: {
|
|
eligibleUsers: 1_240,
|
|
retainedUsers: 508,
|
|
rateBasisPoints: 4_096,
|
|
},
|
|
day7Retention: {
|
|
eligibleUsers: 1_102,
|
|
retainedUsers: 198,
|
|
rateBasisPoints: 1_796,
|
|
},
|
|
visitUsers: 942,
|
|
totalVisitUsers: 31_204,
|
|
visitCount: 5_180,
|
|
totalVisitCount: 184_902,
|
|
currentUsers: 63,
|
|
},
|
|
charts: [
|
|
{
|
|
id: 'new_users',
|
|
title: '新增注册',
|
|
unit: '人',
|
|
total: 128,
|
|
buckets: [
|
|
{ key: '2026-09-23', label: '09-23', value: 12 },
|
|
{ key: '2026-09-24', label: '09-24', value: 18 },
|
|
{ key: '2026-09-25', label: '09-25', value: 15 },
|
|
{ key: '2026-09-26', label: '09-26', value: 22 },
|
|
{ key: '2026-09-27', label: '09-27', value: 24 },
|
|
{ key: '2026-09-28', label: '09-28', value: 21 },
|
|
{ key: '2026-09-29', label: '09-29', value: 16 },
|
|
],
|
|
},
|
|
{
|
|
id: 'generated_assets',
|
|
title: '生成素材',
|
|
unit: '个',
|
|
total: 438,
|
|
buckets: [
|
|
{ key: '2026-09-23', label: '09-23', value: 52 },
|
|
{ key: '2026-09-24', label: '09-24', value: 61 },
|
|
{ key: '2026-09-25', label: '09-25', value: 58 },
|
|
{ key: '2026-09-26', label: '09-26', value: 70 },
|
|
{ key: '2026-09-27', label: '09-27', value: 66 },
|
|
{ key: '2026-09-28', label: '09-28', value: 74 },
|
|
{ key: '2026-09-29', label: '09-29', value: 57 },
|
|
],
|
|
},
|
|
],
|
|
operations: {
|
|
cards: [
|
|
{ id: 'paid_orders', label: '支付订单', value: 37, unit: '单' },
|
|
{ id: 'refund_orders', label: '退款订单', value: 4, unit: '单' },
|
|
{ id: 'published_games', label: '公开游戏', value: 12, unit: '个' },
|
|
],
|
|
assetKindBreakdown: [
|
|
{ key: 'image', label: '图片', value: 268 },
|
|
{ key: 'video', label: '视频', value: 74 },
|
|
{ key: 'audio', label: '音频', value: 58 },
|
|
{ key: 'text', label: '文本', value: 38 },
|
|
],
|
|
moduleVisitBreakdown: [
|
|
{ key: 'image-editor', label: '图片画板', value: 2_180 },
|
|
{ key: 'agc', label: '独立 App', value: 1_640 },
|
|
{ key: 'profile', label: '个人中心', value: 1_360 },
|
|
],
|
|
},
|
|
warnings: [],
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const userDetail = {
|
|
userId: 'u_1001',
|
|
publicUserCode: 'TN-1001',
|
|
displayName: '阿陶',
|
|
avatarUrl: avatar('陶', '#b6623f'),
|
|
phoneBound: true,
|
|
createdAt: iso(60 * 24 * 120),
|
|
lastLoginAt: iso(30),
|
|
wallet: {
|
|
balance: 1860,
|
|
permanentPoints: 1200,
|
|
spendableBalance: 1660,
|
|
heldPoints: 200,
|
|
walletFrozen: false,
|
|
manualFrozen: false,
|
|
refundDebtFrozen: false,
|
|
},
|
|
rechargeOrders: [],
|
|
restrictions: {},
|
|
tags: ['vip', '内测'],
|
|
};
|
|
|
|
function route(pathname, method, url) {
|
|
if (pathname === '/admin/api/login' && method === 'POST') {
|
|
return { token: 'local-fake-token', admin };
|
|
}
|
|
if (pathname === '/admin/api/me') {
|
|
return { admin };
|
|
}
|
|
if (pathname === '/admin/api/overview') {
|
|
return {
|
|
service: {
|
|
bindHost: '127.0.0.1',
|
|
bindPort: 8082,
|
|
jwtIssuer: 'genarrative-local',
|
|
adminEnabled: true,
|
|
spacetimeServerUrl: 'http://127.0.0.1:3101',
|
|
spacetimeDatabase: 'genarrative-dev',
|
|
},
|
|
database: {
|
|
databaseIdentity: 'local-identity-8f21',
|
|
ownerIdentity: 'owner-identity-4c90',
|
|
hostType: 'standalone',
|
|
schemaTableNames: tableStats.map(([name]) => name),
|
|
tableStats: tableStats.map(([tableName, rowCount, errorMessage]) => ({
|
|
tableName,
|
|
rowCount,
|
|
errorMessage,
|
|
})),
|
|
fetchErrors: [],
|
|
},
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/dashboard') {
|
|
return dashboard;
|
|
}
|
|
if (pathname === '/admin/api/tracking/events') {
|
|
return { entries: trackingEntries, total: trackingEntries.length };
|
|
}
|
|
if (pathname === '/admin/api/tracking/event-keys') {
|
|
return {
|
|
eventKeys: [
|
|
{
|
|
eventKey: 'daily_login',
|
|
eventTitle: '每日登录',
|
|
scopeKinds: ['user'],
|
|
},
|
|
{
|
|
eventKey: 'editor_generate_image',
|
|
eventTitle: '画板生成图片',
|
|
scopeKinds: ['project'],
|
|
},
|
|
{
|
|
eventKey: 'agc_project_publish',
|
|
eventTitle: '发布独立 App 游戏',
|
|
scopeKinds: ['game'],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/profile/invite-codes') {
|
|
return { entries: inviteEntries, operations: inviteOperations };
|
|
}
|
|
if (pathname === '/admin/api/profile/tasks') {
|
|
return {
|
|
entries: [
|
|
{
|
|
taskId: 'task_daily_login',
|
|
title: '每日登录',
|
|
description: '登录后到任务中心领取',
|
|
eventKey: 'daily_login',
|
|
cycle: 'daily',
|
|
scopeKind: 'user',
|
|
threshold: 1,
|
|
rewardPoints: 20,
|
|
enabled: true,
|
|
sortOrder: 1,
|
|
createdBy: 'u_admin',
|
|
createdByDisplayName: '本地体验账号',
|
|
createdAt: iso(60 * 24 * 30),
|
|
updatedBy: 'u_admin',
|
|
updatedByDisplayName: '本地体验账号',
|
|
updatedAt: iso(60 * 6),
|
|
},
|
|
{
|
|
taskId: 'task_generate_image',
|
|
title: '生成 3 张图片',
|
|
description: '画板生成成功后计数',
|
|
eventKey: 'editor_generate_image',
|
|
cycle: 'daily',
|
|
scopeKind: 'project',
|
|
threshold: 3,
|
|
rewardPoints: 60,
|
|
enabled: true,
|
|
sortOrder: 2,
|
|
createdBy: 'u_admin',
|
|
createdByDisplayName: '本地体验账号',
|
|
createdAt: iso(60 * 24 * 12),
|
|
updatedBy: 'u_admin',
|
|
updatedByDisplayName: '本地体验账号',
|
|
updatedAt: iso(60 * 20),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/agc/tracking-events') {
|
|
return {
|
|
entries: [
|
|
{
|
|
eventId: 'agc_evt_01',
|
|
schemaVersion: 1,
|
|
eventName: 'workspace_opened',
|
|
eventTime: iso(25),
|
|
receivedAt: iso(24),
|
|
userId: 'u_1001',
|
|
editorSessionId: 'session_8801',
|
|
projectId: 'proj_7788',
|
|
creativeTaskId: null,
|
|
agentRunId: null,
|
|
agentTurnId: null,
|
|
status: 'ok',
|
|
errorCode: null,
|
|
source: 'desktop',
|
|
clientVersion: '0.1.158',
|
|
properties: { workspace: 'project-development' },
|
|
batchId: 'batch_agc_01',
|
|
},
|
|
{
|
|
eventId: 'agc_evt_02',
|
|
schemaVersion: 1,
|
|
eventName: 'codex_runner_started',
|
|
eventTime: iso(90),
|
|
receivedAt: iso(89),
|
|
userId: 'u_1002',
|
|
editorSessionId: 'session_8802',
|
|
projectId: 'proj_8100',
|
|
creativeTaskId: 'task_5566',
|
|
agentRunId: 'run_778',
|
|
agentTurnId: 'turn_1',
|
|
status: 'failed',
|
|
errorCode: 'RUNNER_SPAWN_FAILED',
|
|
source: 'desktop',
|
|
clientVersion: '0.1.154',
|
|
properties: { exitCode: -1073741502 },
|
|
batchId: 'batch_agc_02',
|
|
},
|
|
],
|
|
nextCursor: 'agc-cursor-2',
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/feature-gates') {
|
|
return { gates };
|
|
}
|
|
if (pathname === '/admin/api/profile/recharge-orders') {
|
|
const wallet = (overrides) => ({
|
|
userId: 'u_1001',
|
|
totalBalance: 1_860,
|
|
spendableBalance: 1_660,
|
|
dailyFreePoints: 0,
|
|
membershipLimitedPoints: 0,
|
|
permanentPoints: 1_200,
|
|
heldPoints: 200,
|
|
refundDebtPoints: 0,
|
|
manualFrozen: false,
|
|
refundDebtFrozen: false,
|
|
walletFrozen: false,
|
|
manualRestriction: null,
|
|
...overrides,
|
|
});
|
|
const orders = [
|
|
{
|
|
orderId: 'order_20260929_01',
|
|
userId: 'user-1',
|
|
user: {
|
|
userId: 'user-1',
|
|
publicUserCode: 'TN-1001',
|
|
displayName: '阿陶',
|
|
avatarUrl: null,
|
|
},
|
|
productId: 'prod_points_600',
|
|
productTitle: '600 泥点',
|
|
productKind: 'points',
|
|
amountCents: 3_000,
|
|
paidAmountCents: 3_000,
|
|
status: 'paid',
|
|
paymentChannel: 'wechat_native',
|
|
paidAtMicros: 1_790_640_000_000_000,
|
|
providerTransactionId: 'wx_42000012342026092901',
|
|
createdAtMicros: 1_790_640_000_000_000,
|
|
pointsDelta: 600,
|
|
cumulativeSuccessRefundCents: 0,
|
|
targetRecoveryPoints: 0,
|
|
recoveredPoints: 0,
|
|
unrecoveredPoints: 0,
|
|
recoveryStatus: null,
|
|
refunds: [],
|
|
activeHold: null,
|
|
remainingRefundableCents: 3_000,
|
|
refundEligible: true,
|
|
refundBlockReasonCode: null,
|
|
wallet: wallet({}),
|
|
},
|
|
{
|
|
orderId: 'order_20260928_07',
|
|
userId: 'user-2',
|
|
user: {
|
|
userId: 'user-2',
|
|
publicUserCode: 'TN-1002',
|
|
displayName: '小满',
|
|
avatarUrl: null,
|
|
},
|
|
productId: 'prod_points_1200',
|
|
productTitle: '1200 泥点 + 赠送 100',
|
|
productKind: 'points',
|
|
amountCents: 6_000,
|
|
paidAmountCents: 6_000,
|
|
status: 'refunded',
|
|
paymentChannel: 'wechat_mp',
|
|
paidAtMicros: 1_790_560_000_000_000,
|
|
providerTransactionId: 'wx_42000012342026092807',
|
|
createdAtMicros: 1_790_560_000_000_000,
|
|
pointsDelta: 1_300,
|
|
cumulativeSuccessRefundCents: 6_000,
|
|
targetRecoveryPoints: 1_300,
|
|
recoveredPoints: 980,
|
|
unrecoveredPoints: 320,
|
|
recoveryStatus: 'partial',
|
|
refunds: [
|
|
{
|
|
refundId: 'refund_01',
|
|
outRefundNo: 'out_refund_01',
|
|
status: 'succeeded',
|
|
amountCents: 6_000,
|
|
reason: '用户申请退款',
|
|
createdAtMicros: 1_790_570_000_000_000,
|
|
updatedAtMicros: 1_790_580_000_000_000,
|
|
providerRefundId: 'wx_refund_01',
|
|
lastErrorCode: null,
|
|
operatorDisplayName: '本地体验账号',
|
|
},
|
|
],
|
|
activeHold: null,
|
|
remainingRefundableCents: 0,
|
|
refundEligible: false,
|
|
refundBlockReasonCode: 'already_refunded',
|
|
wallet: wallet({
|
|
userId: 'user-2',
|
|
totalBalance: 320,
|
|
spendableBalance: 0,
|
|
permanentPoints: 0,
|
|
heldPoints: 320,
|
|
refundDebtPoints: 320,
|
|
refundDebtFrozen: true,
|
|
walletFrozen: true,
|
|
}),
|
|
},
|
|
];
|
|
return {
|
|
orders,
|
|
total: orders.length,
|
|
limit: 50,
|
|
offset: 0,
|
|
scannedCount: orders.length,
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/profile/recharge-products') {
|
|
const product = (
|
|
productId,
|
|
title,
|
|
priceCents,
|
|
pointsAmount,
|
|
bonusPoints,
|
|
) => ({
|
|
productId,
|
|
title,
|
|
priceCents,
|
|
kind: 'points',
|
|
pointsAmount,
|
|
bonusPoints,
|
|
durationDays: 0,
|
|
badgeLabel: bonusPoints ? `赠送 ${bonusPoints}` : '',
|
|
description: `${pointsAmount} 泥点`,
|
|
tier: 'none',
|
|
membershipPeriodPoints: 0,
|
|
membershipPeriodDays: 0,
|
|
membershipQueueLimit: 0,
|
|
membershipDiscountBps: 0,
|
|
enabled: true,
|
|
sortOrder: 1,
|
|
createdBy: 'u_admin',
|
|
createdAt: iso(60 * 24 * 30),
|
|
updatedBy: 'u_admin',
|
|
updatedAt: iso(60 * 24 * 2),
|
|
});
|
|
return {
|
|
entries: [
|
|
product('prod_points_600', '600 泥点', 3_000, 600, 0),
|
|
product('prod_points_1200', '1200 泥点 + 赠送 100', 6_000, 1_200, 100),
|
|
],
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/profile/redeem-codes') {
|
|
return {
|
|
entries: [
|
|
{
|
|
userId: 'u_9001',
|
|
code: 'WELCOME-600',
|
|
rewardPoints: 600,
|
|
mode: 'once',
|
|
metadata: { campaign: '2026-09 新玩家' },
|
|
startsAt: iso(60 * 24 * 30),
|
|
expiresAt: null,
|
|
status: 'active',
|
|
createdAt: iso(60 * 24 * 30),
|
|
updatedAt: iso(60 * 24 * 3),
|
|
},
|
|
{
|
|
userId: 'u_9002',
|
|
code: 'SUMMER-200',
|
|
rewardPoints: 200,
|
|
mode: 'once',
|
|
metadata: {},
|
|
startsAt: null,
|
|
expiresAt: iso(-60 * 24 * 5),
|
|
status: 'expired',
|
|
createdAt: iso(60 * 24 * 90),
|
|
updatedAt: iso(60 * 24 * 88),
|
|
},
|
|
],
|
|
operations: [
|
|
{
|
|
operationId: 'op_redeem_1',
|
|
codeKind: 'redeem',
|
|
code: 'WELCOME-600',
|
|
action: 'create',
|
|
operatorUserId: 'u_admin',
|
|
operatorDisplayName: '本地体验账号',
|
|
createdAt: iso(60 * 24 * 30),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/accounts') {
|
|
return {
|
|
accounts: [
|
|
{
|
|
accountId: 'acc_owner',
|
|
username: 'owner',
|
|
displayName: '本地体验账号',
|
|
accountRole: 'owner',
|
|
enabled: true,
|
|
tabPermissions: [],
|
|
createdAt: iso(60 * 24 * 200),
|
|
updatedAt: iso(60 * 24 * 2),
|
|
},
|
|
{
|
|
accountId: 'acc_ops',
|
|
username: 'ops_chen',
|
|
displayName: '陈运营',
|
|
accountRole: 'member',
|
|
enabled: true,
|
|
tabPermissions: ['tracking', 'error-reports', 'invite'],
|
|
createdAt: iso(60 * 24 * 90),
|
|
updatedAt: iso(60 * 24 * 5),
|
|
},
|
|
{
|
|
accountId: 'acc_review',
|
|
username: 'review_liu',
|
|
displayName: '刘审核',
|
|
accountRole: 'member',
|
|
enabled: false,
|
|
tabPermissions: ['editor-showcase', 'game-distribution'],
|
|
createdAt: iso(60 * 24 * 45),
|
|
updatedAt: iso(60 * 24 * 30),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/database/tables') {
|
|
return {
|
|
tables: [...tableStats.map(([name]) => name), 'external_api_key'],
|
|
fetchErrors: [],
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/external-api-keys') {
|
|
const keys = [
|
|
{
|
|
keyId: 'key_01',
|
|
ownerUserId: 'u_1001',
|
|
publicUserCode: 'TN-1001',
|
|
name: '画板自动化',
|
|
keyPrefix: 'gnr_live_9f2a',
|
|
scopes: ['canvas.write', 'asset.read'],
|
|
createdAt: iso(60 * 24 * 30),
|
|
lastUsedAt: iso(90),
|
|
revokedAt: null,
|
|
updatedAt: iso(90),
|
|
status: 'active',
|
|
},
|
|
{
|
|
keyId: 'key_02',
|
|
ownerUserId: 'u_1002',
|
|
publicUserCode: 'TN-1002',
|
|
name: '外部编辑器(已撤销)',
|
|
keyPrefix: 'gnr_live_4b71',
|
|
scopes: ['canvas.read'],
|
|
createdAt: iso(60 * 24 * 60),
|
|
lastUsedAt: iso(60 * 24 * 20),
|
|
revokedAt: iso(60 * 24 * 19),
|
|
updatedAt: iso(60 * 24 * 19),
|
|
status: 'revoked',
|
|
},
|
|
];
|
|
const sortColumn = url.searchParams.get('sortColumn') ?? 'createdAt';
|
|
const sortDirection =
|
|
(url.searchParams.get('sortDirection') ?? 'desc') === 'asc' ? 1 : -1;
|
|
const sorted = [...keys].sort(
|
|
(left, right) =>
|
|
String(left[sortColumn] ?? '').localeCompare(
|
|
String(right[sortColumn] ?? ''),
|
|
'zh-CN',
|
|
) * sortDirection,
|
|
);
|
|
return {
|
|
keys: sorted,
|
|
total: sorted.length,
|
|
limit: Number(url.searchParams.get('limit') ?? 50) || 50,
|
|
offset: Number(url.searchParams.get('offset') ?? 0) || 0,
|
|
scannedCount: sorted.length,
|
|
scanLimit: 500,
|
|
scanLimitReached: false,
|
|
};
|
|
}
|
|
if (/^\/admin\/api\/database\/tables\/[^/]+\/rows$/.test(pathname)) {
|
|
const columns = [
|
|
'invitee_user_id',
|
|
'inviter_user_id',
|
|
'invite_code',
|
|
'bound_at',
|
|
];
|
|
const allRows = [
|
|
{
|
|
key: 'row_1',
|
|
cells: {
|
|
invitee_user_id: 'u_1001',
|
|
inviter_user_id: 'u_1002',
|
|
invite_code: 'TAONIER-VIP-2026',
|
|
bound_at: iso(120),
|
|
},
|
|
},
|
|
{
|
|
key: 'row_2',
|
|
cells: {
|
|
invitee_user_id: 'u_1003',
|
|
inviter_user_id: 'u_1002',
|
|
invite_code: 'TAONIER-VIP-2026',
|
|
bound_at: iso(600),
|
|
},
|
|
},
|
|
{
|
|
key: 'row_3',
|
|
cells: {
|
|
invitee_user_id: 'u_1004',
|
|
inviter_user_id: 'u_1005',
|
|
invite_code: 'BETA-CREATOR',
|
|
bound_at: iso(2880),
|
|
},
|
|
},
|
|
];
|
|
// 后台的排序是服务端排序,假 API 也按 sortColumn / sortDirection 重排,
|
|
// 否则本地点表头看不出任何变化。
|
|
const sortColumn = url.searchParams.get('sortColumn') ?? '';
|
|
const sortDirection =
|
|
url.searchParams.get('sortDirection') === 'desc' ? -1 : 1;
|
|
const sortedRows = sortColumn
|
|
? [...allRows].sort((left, right) => {
|
|
const l = String(left.cells[sortColumn] ?? '');
|
|
const r = String(right.cells[sortColumn] ?? '');
|
|
return l.localeCompare(r, 'zh-CN') * sortDirection;
|
|
})
|
|
: allRows;
|
|
const limit = Number(url.searchParams.get('limit') ?? 100) || 100;
|
|
const page = Number(url.searchParams.get('page') ?? 1) || 1;
|
|
const pageRows = sortedRows.slice((page - 1) * limit, page * limit);
|
|
return {
|
|
tableName: 'profile_referral_relation',
|
|
columns,
|
|
rows: pageRows,
|
|
totalReturned: pageRows.length,
|
|
limit,
|
|
page,
|
|
totalMatched: sortedRows.length,
|
|
scannedCount: sortedRows.length,
|
|
scanLimit: 50_000,
|
|
scanLimitReached: false,
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/agc-templates') {
|
|
return {
|
|
revision: 'rev_local_7',
|
|
writable: true,
|
|
templates: [
|
|
{
|
|
id: 'tpl_platformer',
|
|
title: '平台跳跃模板',
|
|
summary: '横版跳跃基础关卡、金币与终点判定,可直接改名改封面。',
|
|
tags: ['横版', '内置美术'],
|
|
runtime: 'web',
|
|
engine: 'Genarrative Runtime',
|
|
engineVersion: '1.4.0',
|
|
templateVersion: '3',
|
|
enabled: true,
|
|
coverUrl: cover('平台跳跃', '#b6623f'),
|
|
zipSizeBytes: 4_318_712,
|
|
},
|
|
{
|
|
id: 'tpl_puzzle',
|
|
title: '拼图关模板',
|
|
summary: '带参考图的拼图关卡,支持难度与提示次数配置。',
|
|
tags: ['拼图', '休闲'],
|
|
runtime: 'web',
|
|
engine: 'Genarrative Runtime',
|
|
engineVersion: '1.4.0',
|
|
templateVersion: '2',
|
|
enabled: false,
|
|
coverUrl: cover('拼图关', '#8f4b26'),
|
|
zipSizeBytes: 2_004_120,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/agc-models') {
|
|
return {
|
|
revision: 7,
|
|
defaultModelId: 'model_sol',
|
|
models: [
|
|
{
|
|
id: 'model_sol',
|
|
alias: '标准模型',
|
|
modelId: 'gpt-5.6-sol',
|
|
enabled: true,
|
|
},
|
|
{
|
|
id: 'model_terra',
|
|
alias: '快速模型',
|
|
modelId: 'gpt-5.6-terra',
|
|
enabled: true,
|
|
},
|
|
{
|
|
id: 'model_legacy',
|
|
alias: '旧版模型',
|
|
modelId: 'gpt-5.4',
|
|
enabled: false,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/game-distribution/games') {
|
|
const game = (gameId, title, authorName, status, playCount, versions) => ({
|
|
gameId,
|
|
title,
|
|
author: { id: `u_${gameId}`, name: authorName, avatarUrl: null },
|
|
status,
|
|
versionCount: versions.length,
|
|
playCount,
|
|
activeVersionId: versions[0]?.versionId ?? null,
|
|
publicationRevision: versions[0]?.versionNumber ?? 0,
|
|
createdAt: iso(60 * 24 * 20),
|
|
updatedAt: iso(60 * 3),
|
|
versions,
|
|
});
|
|
const version = (versionId, versionNumber, status, bytes) => ({
|
|
versionId,
|
|
versionNumber,
|
|
status,
|
|
packageBytes: bytes,
|
|
packageSha256: `${versionId}0f4e9d1c7b3a5e8f2b6d4c9a1e7f3b5d8c2a4e6f`,
|
|
createdAt: iso(60 * 24 * 5),
|
|
reviewedAt: iso(60 * 24 * 4),
|
|
publishedAt: iso(60 * 24 * 3),
|
|
reviewReason: null,
|
|
entryUrl: `https://games.example.com/${versionId}/index.html`,
|
|
});
|
|
return {
|
|
games: [
|
|
game('game_1001', '陶泥小世界', '阿陶', 'published', 345, [
|
|
version('ver_1001_7', 7, 'published', 18_400_000),
|
|
version('ver_1001_6', 6, 'published', 17_900_000),
|
|
]),
|
|
game('game_1002', '纸片拼图计划', '小满', 'suspended', 96, [
|
|
version('ver_1002_3', 3, 'suspended', 5_120_004),
|
|
]),
|
|
game('game_1003', '三消工坊', '老周', 'unpublished', 12, []),
|
|
],
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/editor-assets') {
|
|
return {
|
|
entries: [
|
|
editorAsset('asset_01', '红发精灵立绘', 'u_1001', '阿陶', 'TN-1001'),
|
|
editorAsset('asset_02', '森林场景背景', 'u_1002', '小满', 'TN-1002'),
|
|
],
|
|
nextCursor: null,
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/editor-showcase/assets') {
|
|
return {
|
|
entries: [
|
|
showcaseAsset(
|
|
'showcase_01',
|
|
'蒸汽朋克小镇',
|
|
'u_1002',
|
|
'小满',
|
|
'TN-1002',
|
|
),
|
|
showcaseAsset('showcase_02', '纸片森林', 'u_1003', '老周', 'TN-1003'),
|
|
],
|
|
nextCursor: null,
|
|
};
|
|
}
|
|
if (pathname === '/admin/api/project-snapshots/channels') {
|
|
return { defaultChannel: 'dev', channels: ['dev', 'release'] };
|
|
}
|
|
if (pathname === '/admin/api/project-snapshots') {
|
|
return { items: snapshotItems, nextCursor: 'cursor-2' };
|
|
}
|
|
if (pathname === '/admin/api/error-reports') {
|
|
return {
|
|
reports: errorReports,
|
|
total: errorReports.length,
|
|
offset: 0,
|
|
limit: 20,
|
|
hasMore: false,
|
|
};
|
|
}
|
|
if (pathname.startsWith('/admin/api/error-reports/')) {
|
|
return errorReportDetail;
|
|
}
|
|
if (pathname === '/admin/api/profile/users/detail') {
|
|
return userDetail;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
const server = http.createServer((request, response) => {
|
|
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
|
|
const payload = route(url.pathname, request.method ?? 'GET', url);
|
|
const body = JSON.stringify({ data: payload });
|
|
response.writeHead(200, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'x-genarrative-response-envelope': 'v1',
|
|
});
|
|
response.end(body);
|
|
});
|
|
|
|
server.listen(port, '127.0.0.1', () => {
|
|
console.log(`fake admin api on http://127.0.0.1:${port}`);
|
|
});
|