diff --git a/.codex/skills/genarrative-gameplay-entry-type/agents/openai.yaml b/.codex/skills/genarrative-gameplay-entry-type/agents/openai.yaml new file mode 100644 index 00000000..1fb1aa12 --- /dev/null +++ b/.codex/skills/genarrative-gameplay-entry-type/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "新增玩法入口" + short_description: "把新增玩法入口的文档、配置、路由和验证流程一次收口" + default_prompt: "Use $genarrative-gameplay-entry-type to add a new gameplay entry type end to end in Genarrative." diff --git a/README.md b/README.md index 3f07df16..411670a0 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ npm run dev 补充说明: -- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server` 与 Vite 前端,适合完整联调。 +- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 与后台 Vite,适合完整联调。 +- 主站默认地址是 `http://127.0.0.1:3000`,后台可从 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`。 - 如果只想单独启动前端页面,可使用 `npm run dev:web`,默认代理到本地 Rust `api-server`。 构建生产包: diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index c4203bfd..94d4fce1 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -2,16 +2,22 @@ import type { AdminDebugHttpRequest, AdminDebugHttpResponse, AdminDisableProfileRedeemCodeRequest, + AdminDisableProfileTaskConfigRequest, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest, + AdminUpsertProfileTaskConfigRequest, ApiErrorEnvelope, ApiMeta, ApiSuccessEnvelope, + ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse, + ProfileRedeemCodeAdminListResponse, ProfileRedeemCodeAdminResponse, + ProfileTaskConfigAdminListResponse, + ProfileTaskConfigAdminResponse, } from './adminApiTypes'; const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope'; @@ -129,6 +135,13 @@ export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) { }); } +export function listProfileRedeemCodes(token: string) { + return request( + '/admin/api/profile/redeem-codes', + {token}, + ); +} + export function upsertProfileRedeemCode( token: string, payload: AdminUpsertProfileRedeemCodeRequest, @@ -143,6 +156,13 @@ export function upsertProfileRedeemCode( ); } +export function listProfileInviteCodes(token: string) { + return request( + '/admin/api/profile/invite-codes', + {token}, + ); +} + export function upsertProfileInviteCode( token: string, payload: AdminUpsertProfileInviteCodeRequest, @@ -171,6 +191,38 @@ export function disableProfileRedeemCode( ); } +export function listProfileTaskConfigs(token: string) { + return request( + '/admin/api/profile/tasks', + {token}, + ); +} + +export function upsertProfileTaskConfig( + token: string, + payload: AdminUpsertProfileTaskConfigRequest, +) { + return request('/admin/api/profile/tasks', { + method: 'POST', + token, + body: payload, + }); +} + +export function disableProfileTaskConfig( + token: string, + payload: AdminDisableProfileTaskConfigRequest, +) { + return request( + '/admin/api/profile/tasks/disable', + { + method: 'POST', + token, + body: payload, + }, + ); +} + function normalizeBaseUrl(value: string) { return value.trim().replace(/\/+$/, ''); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index a9201f87..cd7bc74c 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -106,6 +106,8 @@ export interface AdminDebugHttpResponse { } export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private'; +export type ProfileTaskCycle = 'daily'; +export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user'; export interface AdminUpsertProfileRedeemCodeRequest { code: string; @@ -126,6 +128,23 @@ export interface AdminDisableProfileRedeemCodeRequest { code: string; } +export interface AdminUpsertProfileTaskConfigRequest { + taskId: string; + title: string; + description?: string | null; + eventKey: string; + cycle: ProfileTaskCycle; + scopeKind: TrackingScopeKind; + threshold: number; + rewardPoints: number; + enabled: boolean; + sortOrder: number; +} + +export interface AdminDisableProfileTaskConfigRequest { + taskId: string; +} + export interface ProfileRedeemCodeAdminResponse { code: string; mode: ProfileRedeemCodeMode; @@ -139,6 +158,10 @@ export interface ProfileRedeemCodeAdminResponse { updatedAt: string; } +export interface ProfileRedeemCodeAdminListResponse { + entries: ProfileRedeemCodeAdminResponse[]; +} + export interface ProfileInviteCodeAdminResponse { userId: string; inviteCode: string; @@ -146,3 +169,28 @@ export interface ProfileInviteCodeAdminResponse { createdAt: string; updatedAt: string; } + +export interface ProfileInviteCodeAdminListResponse { + entries: ProfileInviteCodeAdminResponse[]; +} + +export interface ProfileTaskConfigAdminResponse { + taskId: string; + title: string; + description: string; + eventKey: string; + cycle: ProfileTaskCycle; + scopeKind: TrackingScopeKind; + threshold: number; + rewardPoints: number; + enabled: boolean; + sortOrder: number; + createdBy: string; + createdAt: string; + updatedBy: string; + updatedAt: string; +} + +export interface ProfileTaskConfigAdminListResponse { + entries: ProfileTaskConfigAdminResponse[]; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 498db96f..a200d35d 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -10,6 +10,7 @@ import type { AdminSessionPayload, ProfileInviteCodeAdminResponse, ProfileRedeemCodeAdminResponse, + ProfileTaskConfigAdminResponse, } from '../api/adminApiTypes'; import { clearStoredAdminToken, @@ -21,6 +22,7 @@ import {AdminInviteCodePage} from '../pages/AdminInviteCodePage'; import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; +import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage'; import {AdminShell} from './AdminShell'; import type {AdminRouteId} from './adminRoutes'; import {resolveAdminRoute, routeHash} from './adminRoutes'; @@ -40,6 +42,8 @@ export function AdminApp() { useState(null); const [inviteResult, setInviteResult] = useState(null); + const [taskConfigResult, setTaskConfigResult] = + useState(null); const clearSession = useCallback((message = '') => { clearStoredAdminToken(); @@ -47,6 +51,7 @@ export function AdminApp() { setAdmin(null); setRedeemResult(null); setInviteResult(null); + setTaskConfigResult(null); setStatus('guest'); setLoginNotice(message); }, []); @@ -115,6 +120,7 @@ export function AdminApp() { setAdmin(response.admin); setRedeemResult(null); setInviteResult(null); + setTaskConfigResult(null); setLoginNotice(''); setStatus('authenticated'); }, []); @@ -172,6 +178,14 @@ export function AdminApp() { onResultChange={setInviteResult} /> ) : null} + {routeId === 'tasks' ? ( + + ) : null} ); } diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 47a7c0d0..d5295039 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -3,6 +3,7 @@ import { LayoutDashboard, LogOut, ShieldCheck, + ListChecks, TicketCheck, TicketPercent, } from 'lucide-react'; @@ -25,6 +26,7 @@ const routeIcons = { debug: Bug, redeem: TicketPercent, invite: TicketCheck, + tasks: ListChecks, } satisfies Record; export function AdminShell({ diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index ee9ba760..a73c44db 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -1,4 +1,4 @@ -export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite'; +export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite' | 'tasks'; export interface AdminRouteDefinition { id: AdminRouteId; @@ -11,6 +11,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ {id: 'debug', label: 'API 调试', hash: '#debug'}, {id: 'redeem', label: '兑换码', hash: '#redeem'}, {id: 'invite', label: '邀请码', hash: '#invite'}, + {id: 'tasks', label: '任务配置', hash: '#tasks'}, ]; export function resolveAdminRoute(hash: string): AdminRouteId { diff --git a/apps/admin-web/src/config/trackingEventDefinitions.ts b/apps/admin-web/src/config/trackingEventDefinitions.ts new file mode 100644 index 00000000..2e068809 --- /dev/null +++ b/apps/admin-web/src/config/trackingEventDefinitions.ts @@ -0,0 +1,45 @@ +import type {TrackingScopeKind} from '../api/adminApiTypes'; + +export interface AdminTrackingEventDefinition { + key: string; + title: string; + scopeKind: TrackingScopeKind; + remark: string; +} + +export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [ + { + key: 'daily_login', + title: '每日登录', + scopeKind: 'user', + remark: '用户打开任务中心时由后端幂等记录,用于每日登录任务进度校验。', + }, +]; + +export function findAdminTrackingEventDefinition(eventKey: string) { + const normalizedEventKey = eventKey.trim(); + return ( + adminTrackingEventDefinitions.find( + (definition) => definition.key === normalizedEventKey, + ) ?? null + ); +} + +export function filterAdminTrackingEventDefinitions(query: string) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return adminTrackingEventDefinitions; + } + + return adminTrackingEventDefinitions.filter((definition) => { + const haystack = [ + definition.key, + definition.title, + definition.scopeKind, + definition.remark, + ] + .join(' ') + .toLowerCase(); + return haystack.includes(normalizedQuery); + }); +} diff --git a/apps/admin-web/src/pages/AdminInviteCodePage.tsx b/apps/admin-web/src/pages/AdminInviteCodePage.tsx index ce927c6d..a507ef4d 100644 --- a/apps/admin-web/src/pages/AdminInviteCodePage.tsx +++ b/apps/admin-web/src/pages/AdminInviteCodePage.tsx @@ -1,7 +1,10 @@ -import {Save} from 'lucide-react'; -import {FormEvent, useState} from 'react'; +import {RefreshCcw, Save} from 'lucide-react'; +import {FormEvent, useEffect, useState} from 'react'; -import {upsertProfileInviteCode} from '../api/adminApiClient'; +import { + listProfileInviteCodes, + upsertProfileInviteCode, +} from '../api/adminApiClient'; import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes'; import {handlePageError} from './pageUtils'; @@ -21,7 +24,28 @@ export function AdminInviteCodePage({ const [inviteCode, setInviteCode] = useState(''); const [metadataText, setMetadataText] = useState('{}'); const [errorMessage, setErrorMessage] = useState(''); + const [listErrorMessage, setListErrorMessage] = useState(''); + const [entries, setEntries] = useState([]); const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + void refreshInviteCodes(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshInviteCodes() { + setIsLoading(true); + setListErrorMessage(''); + try { + const response = await listProfileInviteCodes(token); + setEntries(response.entries); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setListErrorMessage); + } finally { + setIsLoading(false); + } + } async function handleSave(event: FormEvent) { event.preventDefault(); @@ -37,6 +61,8 @@ export function AdminInviteCodePage({ metadata: parseMetadata(metadataText), }); onResultChange(response); + upsertEntry(response); + fillForm(response); } catch (error: unknown) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -44,6 +70,28 @@ export function AdminInviteCodePage({ } } + function upsertEntry(next: ProfileInviteCodeAdminResponse) { + setEntries((current) => { + const rest = current.filter((entry) => entry.inviteCode !== next.inviteCode); + return [...rest, next].sort((left, right) => { + const leftUpdatedAt = Date.parse(left.updatedAt); + const rightUpdatedAt = Date.parse(right.updatedAt); + if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) { + const updatedCompare = rightUpdatedAt - leftUpdatedAt; + if (updatedCompare !== 0) { + return updatedCompare; + } + } + return left.inviteCode.localeCompare(right.inviteCode); + }); + }); + } + + function fillForm(entry: ProfileInviteCodeAdminResponse) { + setInviteCode(entry.inviteCode); + setMetadataText(JSON.stringify(entry.metadata, null, 2)); + } + return (
@@ -51,8 +99,23 @@ export function AdminInviteCodePage({

邀请码

注册链路预置码

+ + {listErrorMessage ? ( +
+ {listErrorMessage} +
+ ) : null} +
+ +
+
+

记录

+ {result?.inviteCode ?? '-'} +
+ {result ? ( +
+
+
邀请码
+
{result.inviteCode}
+
+
+
创建
+
{result.createdAt}
+
+
+
更新
+
{result.updatedAt}
+
+
+
Metadata
+
+
+                      {JSON.stringify(result.metadata, null, 2)}
+                    
+
+
+
+ ) : ( +
暂无记录
+ )} +
+ ); diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index a346c8f1..373634e9 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -1,8 +1,9 @@ -import {PowerOff, Save} from 'lucide-react'; -import {FormEvent, useState} from 'react'; +import {PowerOff, RefreshCcw, Save} from 'lucide-react'; +import {FormEvent, useEffect, useState} from 'react'; import { disableProfileRedeemCode, + listProfileRedeemCodes, upsertProfileRedeemCode, } from '../api/adminApiClient'; import type { @@ -40,8 +41,29 @@ export function AdminRedeemCodePage({ const [disableCode, setDisableCode] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [disableErrorMessage, setDisableErrorMessage] = useState(''); + const [listErrorMessage, setListErrorMessage] = useState(''); + const [entries, setEntries] = useState([]); const [isSaving, setIsSaving] = useState(false); const [isDisabling, setIsDisabling] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + void refreshRedeemCodes(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshRedeemCodes() { + setIsLoading(true); + setListErrorMessage(''); + try { + const response = await listProfileRedeemCodes(token); + setEntries(response.entries); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setListErrorMessage); + } finally { + setIsLoading(false); + } + } async function handleSave(event: FormEvent) { event.preventDefault(); @@ -63,6 +85,8 @@ export function AdminRedeemCodePage({ mode === 'private' ? splitLines(allowedPublicUserCodes) : [], }); onResultChange(response); + upsertEntry(response); + fillForm(response); } catch (error: unknown) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -83,6 +107,8 @@ export function AdminRedeemCodePage({ code: disableCode.trim(), }); onResultChange(response); + upsertEntry(response); + fillForm(response); } catch (error: unknown) { handlePageError(error, onUnauthorized, setDisableErrorMessage); } finally { @@ -90,6 +116,34 @@ export function AdminRedeemCodePage({ } } + function upsertEntry(next: ProfileRedeemCodeAdminResponse) { + setEntries((current) => { + const rest = current.filter((entry) => entry.code !== next.code); + return [...rest, next].sort((left, right) => { + const leftUpdatedAt = Date.parse(left.updatedAt); + const rightUpdatedAt = Date.parse(right.updatedAt); + if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) { + const updatedCompare = rightUpdatedAt - leftUpdatedAt; + if (updatedCompare !== 0) { + return updatedCompare; + } + } + return left.code.localeCompare(right.code); + }); + }); + } + + function fillForm(entry: ProfileRedeemCodeAdminResponse) { + setCode(entry.code); + setMode(entry.mode); + setRewardPoints(String(entry.rewardPoints)); + setMaxUses(String(entry.maxUses)); + setEnabled(entry.enabled); + setAllowedUserIds(entry.allowedUserIds.join('\n')); + setAllowedPublicUserCodes(''); + setDisableCode(entry.code); + } + return (
@@ -97,8 +151,23 @@ export function AdminRedeemCodePage({

兑换码

创建、更新与停用

+ + {listErrorMessage ? ( +
+ {listErrorMessage} +
+ ) : null} +
@@ -200,6 +269,48 @@ export function AdminRedeemCodePage({
+
+
+

兑换码列表

+ {entries.length} +
+ {entries.length ? ( +
+ + + + + + + + + + {entries.map((entry) => ( + + + + + + ))} + +
Code奖励状态
+ + {redeemModeLabel(entry.mode)} + {entry.rewardPoints}{entry.enabled ? '启用' : '停用'}
+
+ ) : ( +
+ {isLoading ? '加载中' : '暂无兑换码'} +
+ )} +
+