Files
Genarrative/scripts/admin-web-fake-api.mjs
T
kdletters 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 全部通过
2026-09-29 22:39:41 +08:00

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}`);
});