Merge remote-tracking branch 'origin/master'

This commit is contained in:
Hermes Agent
2026-05-04 03:02:49 +08:00
95 changed files with 8093 additions and 683 deletions
@@ -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."
+2 -1
View File
@@ -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`
构建生产包:
+52
View File
@@ -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<ProfileRedeemCodeAdminListResponse>(
'/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<ProfileInviteCodeAdminListResponse>(
'/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<ProfileTaskConfigAdminListResponse>(
'/admin/api/profile/tasks',
{token},
);
}
export function upsertProfileTaskConfig(
token: string,
payload: AdminUpsertProfileTaskConfigRequest,
) {
return request<ProfileTaskConfigAdminResponse>('/admin/api/profile/tasks', {
method: 'POST',
token,
body: payload,
});
}
export function disableProfileTaskConfig(
token: string,
payload: AdminDisableProfileTaskConfigRequest,
) {
return request<ProfileTaskConfigAdminResponse>(
'/admin/api/profile/tasks/disable',
{
method: 'POST',
token,
body: payload,
},
);
}
function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
+48
View File
@@ -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[];
}
+14
View File
@@ -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<ProfileRedeemCodeAdminResponse | null>(null);
const [inviteResult, setInviteResult] =
useState<ProfileInviteCodeAdminResponse | null>(null);
const [taskConfigResult, setTaskConfigResult] =
useState<ProfileTaskConfigAdminResponse | null>(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' ? (
<AdminTaskConfigPage
result={taskConfigResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setTaskConfigResult}
/>
) : null}
</AdminShell>
);
}
+2
View File
@@ -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<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
+2 -1
View File
@@ -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 {
@@ -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);
});
}
+139 -37
View File
@@ -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<ProfileInviteCodeAdminResponse[]>([]);
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<HTMLFormElement>) {
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 (
<section className="admin-page">
<div className="admin-page-heading">
@@ -51,8 +99,23 @@ export function AdminInviteCodePage({
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshInviteCodes}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<label className="admin-field">
@@ -90,42 +153,81 @@ export function AdminInviteCodePage({
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.inviteCode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>User ID</dt>
<dd>{result.userId}</dd>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.inviteCode}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.inviteCode}
</button>
</td>
<td>{entry.createdAt}</td>
<td>{entry.updatedAt}</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<dt></dt>
<dd>{result.inviteCode}</dd>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无邀请码'}
</div>
<div>
<dt></dt>
<dd>{result.createdAt}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
<div>
<dt>Metadata</dt>
<dd>
<pre className="admin-code-block">
{JSON.stringify(result.metadata, null, 2)}
</pre>
</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
)}
</section>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.inviteCode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{result.inviteCode}</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdAt}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
<div>
<dt>Metadata</dt>
<dd>
<pre className="admin-code-block">
{JSON.stringify(result.metadata, null, 2)}
</pre>
</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</div>
</section>
);
@@ -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<ProfileRedeemCodeAdminResponse[]>([]);
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<HTMLFormElement>) {
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 (
<section className="admin-page">
<div className="admin-page-heading">
@@ -97,8 +151,23 @@ export function AdminRedeemCodePage({
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshRedeemCodes}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row">
@@ -200,6 +269,48 @@ export function AdminRedeemCodePage({
</form>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>Code</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.code}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.code}
</button>
<small>{redeemModeLabel(entry.mode)}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无兑换码'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleDisable}>
<label className="admin-field">
<span> Code</span>
@@ -273,3 +384,7 @@ function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function redeemModeLabel(value: ProfileRedeemCodeMode) {
return redeemModes.find((item) => item.value === value)?.label ?? value;
}
File diff suppressed because it is too large Load Diff
+119 -1
View File
@@ -321,6 +321,13 @@ button:disabled {
outline: none;
}
.admin-field-note {
color: #667682;
font-size: 12px;
font-weight: 500;
line-height: 1.45;
}
.admin-field textarea {
min-height: 112px;
resize: vertical;
@@ -333,6 +340,96 @@ button:disabled {
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
}
.admin-combobox {
position: relative;
min-width: 0;
}
.admin-combobox-control {
display: grid;
grid-template-columns: minmax(0, 1fr) 38px;
}
.admin-combobox-control input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.admin-combobox-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
border: 1px solid #cbd8e0;
border-left: 0;
border-radius: 0 8px 8px 0;
color: #52616d;
background: #fbfdfe;
}
.admin-combobox:focus-within .admin-combobox-toggle {
border-color: #126e82;
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
}
.admin-combobox-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 30;
display: grid;
max-height: 260px;
overflow: auto;
border: 1px solid #cbd8e0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(23, 33, 43, 0.14);
padding: 6px;
}
.admin-combobox-option {
display: grid;
gap: 3px;
width: 100%;
border: 0;
border-radius: 7px;
color: #17212b;
background: transparent;
padding: 9px 10px;
text-align: left;
}
.admin-combobox-option:hover,
.admin-combobox-option:focus-visible {
background: #e7f3f5;
outline: none;
}
.admin-combobox-option span {
color: #0f5666;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
font-weight: 700;
}
.admin-combobox-option small,
.admin-combobox-empty {
color: #667682;
font-size: 12px;
font-weight: 500;
line-height: 1.45;
}
.admin-combobox-empty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px;
}
.admin-switch-field {
display: inline-flex;
align-items: center;
@@ -384,6 +481,16 @@ button:disabled {
background: #eef3f6;
}
.admin-text-button {
display: inline;
border: 0;
color: #0f5666;
background: transparent;
padding: 0;
text-align: left;
font-weight: 700;
}
.admin-alert {
border: 1px solid #efc0bd;
border-radius: 8px;
@@ -443,6 +550,17 @@ button:disabled {
font-size: 12px;
}
.admin-table td small {
display: block;
margin-top: 3px;
color: #667682;
font-size: 12px;
}
.admin-table-compact {
min-width: 360px;
}
.admin-status {
display: inline-flex;
max-width: 460px;
@@ -608,7 +726,7 @@ button:disabled {
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
border-top: 1px solid #d8e2e8;
background: rgba(255, 255, 255, 0.94);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
+2
View File
@@ -15,10 +15,12 @@ export default defineConfig(({mode}) => {
env.ADMIN_API_TARGET ||
env.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
const base = env.ADMIN_WEB_BASE || '/admin/';
return {
root: adminWebRoot,
envDir: repoRoot,
base,
plugins: [react()],
server: {
proxy: {
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Jenkins inbound agent %i
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=root
Group=root
EnvironmentFile=/etc/jenkins-agent/%i.env
WorkingDirectory=/var/lib/jenkins/agent/%i
ExecStart=/usr/local/bin/jenkins-inbound-agent-start %i
Restart=always
RestartSec=10
KillSignal=SIGINT
TimeoutStopSec=30
# 当前生产流水线仍包含服务器初始化、systemd 与 Nginx 写入等特权操作。
# 后续若将 agent 降权到 jenkins 用户,需要先把流水线命令收敛到精确 sudo 白名单。
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+4
View File
@@ -11,6 +11,8 @@
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
- [参考目录](./reference/README.md):脚本/Function 速查入口。
重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。
- [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。
- [PRD](./prd):产品需求与阶段计划;后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
@@ -33,3 +35,5 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段
- `technical/`:偏技术选型、实现路线、竞品/产品形态拆解。
- `planning/`:偏阶段优先级与推进顺序。
- `reference/`:偏目录、速查、检索辅助。
- `tracking/`:偏埋点原始事实和聚合投影查询,不放任务进度或钱包对账。
- `operations/`:偏后台运营核查、对账和排障查询。
@@ -0,0 +1,33 @@
# 个人任务运营查询手册
更新时间:`2026-05-03`
## 任务配置
```powershell
spacetime sql <db> "SELECT * FROM profile_task_config ORDER BY updated_at DESC"
spacetime sql <db> "SELECT * FROM profile_task_config WHERE task_id = 'daily_login'"
```
## 任务进度
```powershell
spacetime sql <db> "SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' ORDER BY updated_at DESC"
spacetime sql <db> "SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' AND task_id = 'daily_login' ORDER BY day_key DESC"
```
## 领奖记录
```powershell
spacetime sql <db> "SELECT * FROM profile_task_reward_claim WHERE user_id = '<user_id>' ORDER BY claimed_at DESC"
spacetime sql <db> "SELECT * FROM profile_task_reward_claim WHERE claim_id = '<user_id>:daily_login:<day_key>'"
```
## 钱包流水对账
```powershell
spacetime sql <db> "SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' AND source_type = 'DailyTaskReward' ORDER BY created_at DESC"
spacetime sql <db> "SELECT * FROM profile_dashboard_state WHERE user_id = '<user_id>'"
```
每日登录奖励流水 ID 为 `task-reward:<user_id>:daily_login:<day_key>`,领奖记录中的 `wallet_ledger_id` 必须能在 `profile_wallet_ledger` 中查到。
+5
View File
@@ -0,0 +1,5 @@
# 运营查询
本目录存放后台运营核查、对账和排障查询,不承载埋点系统本身的查询手册。
- [PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md](./PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md):个人任务配置、进度、领奖记录与钱包流水对账查询。
@@ -151,9 +151,9 @@ Agent 的职责是帮助用户确认可以直接编译 demo 的最小配置:
题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。
首版 demo 不接入真实图片生成。运行态可消除物统一使用纯色几何体表现,不使用透明气泡,也不在图案上放文字标识。题材仍决定后端生成的 `visualKey` 和尺寸比例,但前端首版用差异化颜色与几何造型表现可消除物,例如圆形、三角形、菱形、五角星、梯形、平行四边形等,避免玩家在堆叠状态下难以辨认。
首版 demo 不接入真实图片生成。当前运行态可消除物统一使用参考图方向的 25 个积木件类型表现,不使用透明气泡,也不在图案上放文字标识。前端首版用差异化颜色、积木造型和 3D 程序化模型表现可消除物,避免玩家在堆叠状态下难以辨认。
水果图形资产需要具备常识可感知的相对大小关系,但不要求真实比例绝对精准。首版固定规则为:西瓜明显大于苹果;苹果、橙子、梨、桃子为中等尺寸;葡萄、李子、青柠等小型水果略小。该尺寸由后端运行态物品 `radius` 下发,前端只按快照表现。
可消除物尺寸使用五档相对体积规则:XL 型相对体积为 `1.60~2.30`L 型为 `1.25~1.60`M 型为 `1.00`XS 型为 `0.65~0.85`S 型为 `0.35~0.50`。单局中 XL / L / M / XS / S 按本局使用的消除物类型数的 `20% / 30% / 30% / 15% / 5%` 分配;非整数配额按最大余数补齐,确保总数等于本局使用类型数量。同一关卡内同一个颜色和造型的物品只能对应一个尺寸档位;可存在同尺寸但不同颜色和造型的物品。后端运行态通过 `radius` 下发权威尺寸,前端只按快照表现。
### 需要消除次数
@@ -265,6 +265,16 @@ totalItemCount = clearCount * 3
每种物品数量必须是 `3` 的倍数,避免生成无法通关的局。
生成的消除物类型数由用户填写的需要消除次数决定:
```text
itemTypeCount = clearCount <= 25 ? clearCount : 25
```
`clearCount <= 25` 时,本局生成的 `itemTypeId` 数量等于 `clearCount`,每种类型默认生成 `3` 件;当 `clearCount > 25` 时,本局最多生成 `25``itemTypeId`,后续消除组按这 `25` 种类型轮转补齐,且每种类型最终数量仍必须保持 `3` 的倍数。
同一局内这些类型必须分别使用不同的形状和颜色组合,不能出现两个组看起来像同一种物体的情况。
## 8.4 阶段陆续生成
每局物品允许阶段陆续生成。
@@ -277,8 +287,8 @@ totalItemCount = clearCount * 3
首版 demo 使用 2D 图案素材。
1. demo 至少提供 `10` 种颜色与几何造型组合素材。
2.题材为水果时,后端仍可切换到 `10` 种水果视觉键和尺寸比例,但前端首版必须把这些视觉键映射为无文字的纯色几何体,不能显示为水果图、透明气泡或文字标记。
1. demo 至少提供 `25`彼此不同的颜色与几何造型组合素材,支撑 `clearCount > 25` 时的类型上限
2.前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版必须把这些视觉键映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。
3. 后续可以尝试替换为伪 3D 或 3D 模型。
4. 用户题材主题后续会映射为符合常识预期的物品集合。
@@ -310,6 +320,8 @@ totalItemCount = clearCount * 3
飞行动画过程中,物品不再与其他物品产生碰撞。
当前 3D 实验模式下,物品进入备选栏后必须从圆形空间的物理世界移除;备选栏只展示该物品同款 3D 模型的独立预览,固定为斜 `45` 度便于识别,不再参与场内碰撞、重力或堆叠。
前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。
## 8.9 备选栏
@@ -318,8 +330,9 @@ totalItemCount = clearCount * 3
1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。
2. 备选栏中每出现 `3` 个相同物品 id,前端立即播放自动消除效果并腾出格子。
3. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正
4. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准
3. 3D 模式下,备选栏格子展示从场内取出的同款 3D 模型预览,视角固定斜 `45` 度,不使用另一套不一致的 UI 图标;托盘预览必须共享一个 WebGL renderer,不能因多个预览上下文导致中心场地模型不可见;WebGL 回退或 `2D` 模式下才使用保留的 2D 图标
4. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正
5. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。
## 8.10 胜利
@@ -22,15 +22,16 @@
-`2026-04-19` 起,“最近游玩 / 历史浏览”已从“我的”页迁出,改为平台一级主 Tab“存档”。
- 对应母文档见 [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)。
当前“我的”页保留以下 `7` 个独立功能:
当前“我的”页保留以下 `8` 个独立功能:
1. 账号资料与身份卡
2. 会员中心与充值
3. 我的数据看板
4. 邀请好友
5. 填邀请码
6. 玩家社区
7. 设置与账号安全
6. 每日任务
7. 玩家社区
8. 设置与账号安全
---
@@ -42,8 +43,9 @@
4. [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)
5. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md)
6. [MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md)
7. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md)
8. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md)
7. [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](../technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md)
8. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md)
9. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md)
---
@@ -58,7 +60,8 @@
5. 会员中心与充值
6. 邀请好友
7. 填邀请码
8. 玩家社区
8. 每日任务
9. 玩家社区
原因:
@@ -66,6 +69,7 @@
- `3 + 4` 直接增强账号资产与回流体验,短期收益高
- `5 + 6` 涉及商业化和关系绑定,依赖结算与奖励台账
- `7` 最适合放在平台内容层能力稳定后再做
- `8` 依赖埋点聚合、任务配置和钱包流水,首版只接每日登录
---
@@ -76,6 +80,7 @@
- `PlatformHomeView` 继续作为“我的”Tab 首屏承载层
- 优先采用现有面板、抽屉、弹窗,不新建独立大系统
- 页面只展示后端返回的状态,不自行计算结论型业务状态
- 每日任务入口放在“常用功能”,点击后弹出独立任务面板
### 4.2 后端边界
@@ -96,8 +96,10 @@ export interface ApiErrorEnvelope {
| 当前管理员 | `GET /admin/api/me` | 管理员 Bearer |
| 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer |
| 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer |
| 读取兑换码列表 | `GET /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer |
| 读取后台邀请码列表 | `GET /admin/api/profile/invite-codes` | 管理员 Bearer |
| 创建/更新注册邀请码 | `POST /admin/api/profile/invite-codes` | 管理员 Bearer |
### 4.3 前端类型命名
@@ -206,6 +208,10 @@ export interface ProfileRedeemCodeAdminResponse {
updatedAt: string;
}
export interface ProfileRedeemCodeAdminListResponse {
entries: ProfileRedeemCodeAdminResponse[];
}
export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
@@ -213,6 +219,10 @@ export interface ProfileInviteCodeAdminResponse {
createdAt: string;
updatedAt: string;
}
export interface ProfileInviteCodeAdminListResponse {
entries: ProfileInviteCodeAdminResponse[];
}
```
### 4.4 登录 contract
@@ -284,6 +294,31 @@ export interface ProfileInviteCodeAdminResponse {
### 4.7 兑换码管理 contract
列表请求:
`GET /admin/api/profile/redeem-codes`
成功返回:
```json
{
"entries": [
{
"code": "WELCOME2026",
"mode": "public",
"rewardPoints": 100,
"maxUses": 1,
"globalUsedCount": 0,
"enabled": true,
"allowedUserIds": [],
"createdBy": "admin:root",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
]
}
```
创建/更新请求:
```json
@@ -300,8 +335,6 @@ export interface ProfileInviteCodeAdminResponse {
停用请求:
兑换码管理页的最近一次接口返回记录由 `AdminApp` 维护为管理端会话态,并传入 `AdminRedeemCodePage` 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 `useState` 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。
```json
{
"code": "WELCOME2026"
@@ -325,10 +358,36 @@ export interface ProfileInviteCodeAdminResponse {
}
```
兑换码管理页进入时必须通过 `GET /admin/api/profile/redeem-codes` 加载数据库已有记录。最近一次接口返回记录仍由 `AdminApp` 维护为管理端会话态,用于展示当前操作结果;历史列表不得依赖该会话态,刷新页面后必须从后端列表接口恢复。列表项点击后回填表单,继续通过同一个 `POST /admin/api/profile/redeem-codes` 修改原记录。
前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。
### 4.8 邀请码管理 contract
列表请求:
`GET /admin/api/profile/invite-codes`
成功返回:
```json
{
"entries": [
{
"userId": "admin:root:SPRING2026",
"inviteCode": "SPRING2026",
"metadata": {
"batch": "spring"
},
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
]
}
```
后台邀请码列表只返回后台运营预置码。后端按 `profile_invite_code.user_id``admin:` 前缀过滤,普通用户在邀请中心生成的个人邀请码不得展示在后台列表中。
创建/更新请求:
```json
@@ -372,19 +431,22 @@ export interface ProfileInviteCodeAdminResponse {
3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors`
4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]`
5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。
6. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata,不在前端复制后端邀请码规则
7. 所有按钮的 loading 状态必须锁定重复提交
8. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动
6. 兑换码页和邀请码页进入时加载数据库列表,保存后合并返回记录,点击列表项回填表单进入编辑态
7. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata,不在前端复制后端邀请码规则
8. 所有按钮的 loading 状态必须锁定重复提交
9. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
## 7. 部署与联调
### 7.1 本地联调
1. 启动后端:`npm run api-server`
2. 启动后台前端:在 `apps/admin-web` 执行 `npm run dev`
3. 后台 dev server 通过 Vite proxy 转发 `/admin/api``ADMIN_API_TARGET`;未配置时默认 `http://127.0.0.1:3100`
4. 若使用非 3100 端口,在仓库根目录 `.env.local` 设置 `ADMIN_API_TARGET=http://127.0.0.1:<api-server-port>`,并重启后台前端 dev server
5. `GENARRATIVE_API_PORT` 控制 Rust `api-server` 监听端口;`ADMIN_API_TARGET` 只控制后台前端 dev proxy 目标,二者需要指向同一个端口
1. 完整本地栈直接在仓库根目录执行 `npm run dev`
2. `npm run dev` 默认启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 和后台 Vite
3. 主站默认地址为 `http://127.0.0.1:3000`,后台可从主站 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`
4. 主站 Vite 会把 `/admin/` 转发到后台 dev server,贴近生产同域 `/admin/` 入口
5. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到当前 Rust API 地址;`--api-port` 改动时脚本会同步注入 `ADMIN_API_TARGET`
6. 如需单独启动后台前端,可继续执行根脚本 `npm run admin-web:dev`,或在 `apps/admin-web` 执行 `npm run dev`;单独启动时未配置 `ADMIN_API_TARGET` 会默认代理到 `http://127.0.0.1:3100`
7. 后台 dev 端口可用 `npm run dev -- --admin-web-port <port>` 覆盖。
### 7.2 构建部署
@@ -430,8 +492,8 @@ export interface ProfileInviteCodeAdminResponse {
- token 恢复、过期清理、退出登录。
- 总览页正常数据、部分表统计失败、整体请求失败。
- API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。
- 兑换码 public/unique/private 表单提交和停用。
- 邀请码表单提交、metadata JSON 对象校验和结果展示。
- 兑换码数据库列表加载、列表点击回填、public/unique/private 表单提交和停用。
- 邀请码数据库列表加载、普通用户邀请码不展示、列表点击回填、metadata JSON 对象校验和结果展示。
2. 根工程:
- `npm run check:encoding`
- 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。
@@ -518,15 +518,25 @@ totalItemCount = clearCount * 3
每种 `itemTypeId` 的数量必须是 `3` 的倍数。
消除物类型数按创作输入的 `clearCount` 计算:
```text
itemTypeCount = clearCount <= 25 ? clearCount : 25
```
`clearCount <= 25` 时,运行态快照中的不同 `itemTypeId` 数量必须等于 `clearCount`;当 `clearCount > 25` 时,不同 `itemTypeId` 数量必须等于 `25`。超过 `25` 组的消除目标按这 `25` 种类型轮转生成,确保每种类型的最终数量仍是 `3` 的倍数。
`25` 组在同一局内还必须对应 25 套不同的形状和颜色签名,不能有两组视觉上撞型。
## 9.3 demo 视觉素材
首版使用内置视觉键和前端内置几何图形资产,不接真实图片生成。
首版使用 25 个内置积木件视觉键和前端内置几何图形资产,不接真实图片生成。
1. 水果题材必须使用 `watermelon-green / apple-red / banana-yellow / grape-purple / melon-green / berry-blue / peach-pink / plum-indigo / lime-lime / orange-orange` 这组内置水果视觉键;前端首版将其映射为纯色几何体,不渲染水果写实图,也不能显示为带文字或透明气泡的小球。
2. 非水果题材暂使用 `red_circle / yellow_triangle / purple_diamond / green_square / blue_star / orange_hexagon / cyan_capsule / pink_heart / lime_leaf / white_moon` 这组兜底颜色形状视觉键
3. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品
4. 运行态图案必须使用实心、高饱和、无文字的几何 SVG,至少覆盖圆形、三角形、菱形、方形、五角星、六边形、胶囊、心形、梯形、平行四边形等多种轮廓;外层命中按钮不得再显示半透明气泡底
5. 水果题材的相对尺寸由后端权威半径决定,首版要求西瓜明显大于苹果,苹果、橙子、桃子等中型水果大于葡萄、李子、青柠等小型水果;前端不得自行改写规则半径,只负责按快照表现。
1. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版将其映射为无文字的 2D 图标和程序化 3D 积木模型,不渲染写实图,也不能显示为带文字或透明气泡的小球。
2. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品
3. 运行态图案必须使用实心、高饱和、无文字的几何 SVG,并保持与 3D 模型同一批 `visualKey` 对应关系;外层命中按钮不得再显示半透明气泡底
4. 每局按使用类型数量分配五档相对体积:XL 型 `1.60~2.30``20%`L 型 `1.25~1.60``30%`M 型固定 `1.00``30%`XS 型 `0.65~0.85``15%`S 型 `0.35~0.50``5%`。非整数配额按最大余数补齐,总数必须等于本局使用类型数量
5. 同一局内同一个颜色和造型的 `visualKey` 只能对应一个尺寸档位和一个半径,不能出现同一物品类型三件副本大小不同,也不能出现同一视觉键在复用时被分配到两种大小。前端不得自行改写规则半径,只负责按快照表现。
6. 后续接入真实题材图片素材前,必须另补资产生成方案。
## 9.4 难度
@@ -646,9 +656,10 @@ src/components/match3d-runtime/
1. 圆形空间占据主要区域。
2. 备选栏固定 `7` 格。
3. 倒计时清晰但不遮挡物品
4. 物品点击区域稳定,不因动画造成布局跳动
5. 胜利/失败结算使用独立面板,不在当前面板下方展开
3. 3D 模式下,备选栏格子使用与圆形空间内一致的程序化 3D 模型预览,固定斜 `45` 度视角,且不接入场内物理碰撞;托盘预览必须共享一个 WebGL renderer,不能每格创建独立 renderer;仅 WebGL 回退或 `2D` 模式使用 2D 图标
4. 倒计时清晰但不遮挡物品。
5. 物品点击区域稳定,不因动画造成布局跳动
6. 胜利/失败结算使用独立面板,不在当前面板下方展开。
## 11.5 本地 mock 口径
@@ -19,7 +19,7 @@
1. 现有 `Match3DVisualIcon``Match3DToken` 和托盘 2D 图案渲染代码必须保留。
2. 新增 3D 表现层只作为运行态棋盘的可选渲染分支。
3. 当浏览器不支持 WebGL、3D 依赖加载失败或实验开关关闭时,运行态必须自动回到现有 2D 图案表现。
4. 托盘继续使用当前 2D 图标,便于玩家识别已选物品,也便于实验失败时快速回滚
4. 3D 模式下,托盘直接复用场内同一套程序化 3D 模型,以固定斜 `45` 度识别视角展示已选物品;托盘内物品不进入物理世界,不参与碰撞。WebGL 不可用或实验回退时,托盘继续使用当前 2D 图标。
## 3. 工程落点
@@ -50,7 +50,7 @@ cannon-es
3D 分支只读取后端快照中的物品坐标、层级、可点击状态和视觉键。物理碰撞、轻微堆叠和几何体姿态只作为前端表现层,不改变消除规则、备选栏规则、胜负判定或最终权威快照。
`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。
`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘在 3D 模式下通过 `Match3DTrayPreviewBoard` 使用单个共享 WebGL 预览层复用 `createMatch3DItemMesh` 生成同款 3D 模型,不能为每个托盘格单独创建 `WebGLRenderer`。WebGL 不可用或 2D 回退时继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。
## 4. 验收口径
@@ -58,8 +58,10 @@ cannon-es
2. 3D 几何体保持在圆形区域内,不被圆形边界裁切到不可点。
3. 物体进入场景后有轻微物理碰撞和堆叠稳定过程。
4. 点击 3D 物体后仍执行原有乐观入槽、后端确认、三消反馈和结算。
5. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除
6. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见
5. 被取出的 3D 物体必须立即从棋盘物理世界移除;备选栏展示的是无碰撞、固定角度的独立预览模型,不允许继续受场内碰撞、重力或堆叠影响
6. 托盘 3D 预览必须共享一个 renderer,避免多个 WebGL 上下文导致中心棋盘上下文被浏览器回收;中心棋盘监听 `webglcontextlost`,丢失时自动回退 2D 表现,禁止出现模型不可见但仍可点击的状态
7. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除。
8. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见。
## 5. 锅型容器优化
@@ -72,3 +74,88 @@ cannon-es
3. 物理世界使用同一个锅内半径作为水平活动边界,所有可消除物体的初始位置和运行中位置都必须被约束在圆形锅内。
4. 物体受到重力后只允许在锅内碰撞、滑动、翻滚和向上堆叠,不能因为碰撞或初始坐标散落到圆形区域外。
5. 该优化仍只属于前端 3D 表现层,不改变后端运行态坐标、点击权威判定、备选栏、消除和胜负规则。
## 6. 中心引力优化
2026-05-02 追加中心引力,用来解决高消除次数下 3D 物体过于松散、贴边后被圆形场地裁切的问题。体验后发现默认向心力会让模型过度挤压成团,因此当前先关闭默认引力,只保留代码开关,后续如需再尝试可重新调参。
编码口径:
1. 中心引力默认系数为 `0`,默认不对物理 body 施加水平向心力。
2. 引力只作用在 X/Z 平面,不改变垂直重力,物体仍会自然落到锅底或堆叠在其他物体上。
3. 引力在越靠近锅边时越明显,避免大量物体碰撞后形成稀疏外环;靠近中心时力度收敛,避免所有物体被吸成单点。
4. 锅内活动边界继续作为硬约束;高数量物体应被锅边挡住并向上堆叠,不允许散落到圆形场地外。
5. `/match3d?clearCount=100` 可作为本地直达压力测试入口,用于验证 300 个物体时仍在锅内聚拢。
## 7. 正交俯视与真实场地边界
2026-05-02 针对高堆叠时 3D 物体被 DOM 圆形裁切的问题,明确中心圆形区域不是裁切蒙版,而是游戏实际游玩场地。
编码口径:
1. 3D 棋盘使用正交俯视相机,避免高处物体因为透视放大而投影到圆形场地外。
2. 圆形场地的内圈圆环对应 3D 世界里的锅内空气墙,物体范围由物理约束控制,不再依赖 DOM `overflow-hidden` 裁切。
3. 外层圆形 UI 只负责显示锅沿和场地外观,不能把物体裁成半截;如果物体看起来越界,优先修正相机、物理半径和空气墙。
4. 高数量压力测试以 `/match3d?clearCount=100` 为基准,物体可以在场地内向上堆叠,但不能被圆形边缘压住或切掉。
## 8. 类型数量与样式池历史口径
2026-05-03 曾调整消除物类型生成规则,解决 3D 关卡中可消除物类型和样式过少的问题。该节为历史口径,后续实际实现以第 11 节 25 个积木件资源池为准。
编码口径:
1. 历史版本曾使用 20 类形状颜色组合。
2. 当前版本已替换为 25 个积木件,旧 20 类上限不再作为编码依据。
3. 3D 与 2D 回退仍共用视觉键映射,新增样式不能破坏 `?match3dRender=2d` 回退路径。
## 9. 特殊形状 3D 可读性修正
2026-05-03 针对 20 组关卡中看不到十字、圆环、盾形、闪电、月牙、箭头等新形状的问题,补充 3D 几何体渲染口径。
编码口径:
1. 数据层仍使用 `visualKey` 决定类型,不新增贴图素材或文本标识。
2. 十字、心形、星形、圆环、盾形、闪电、月牙、箭头、V 形等特殊形状不能继续使用普通盒子、球体或锥体代理,必须生成俯视角可辨认的 3D 轮廓。
3. 特殊形状使用 Three.js 程序化轮廓挤出生成,保持当前 3D 实验可快速回退,不影响现有 2D 图案分支。
4. 特殊形状的物理碰撞可以继续使用近似碰撞体,但显示网格需要固定为俯视可读姿态,避免落地翻滚后又变成长方块或普通三角体。
5. 当前特殊形状已被 25 个积木件资源池替换;不能为了让玩家开局肉眼看到全部类型而改动初始层级、物理堆叠、遮挡、边界或可点击规则。
## 10. 15 组中档局面的类型唯一性修正
2026-05-03 针对 `clearCount=15` 时可消除物类型不足 15 种的问题,补充中档局面的规则验收口径。
编码口径:
1. `clearCount=15` 时,运行态数据中必须生成 `15` 种不同 `itemTypeId`,且首个 `15``visualKey` 必须分别对应不同几何形状。
2. 每种 `itemTypeId``clearCount=15` 时只对应 1 次消除目标,即恰好生成 `3` 件物体;同一种视觉模型在同局中不应出现超过 3 件。
3. 不为了展示 15 种而修改初始层级、物理堆叠、遮挡、边界或可点击规则;被盖住、堆叠和局部不可见是正常玩法效果。
4. 当前版本已改为第 11 节的 `itemTypeCount = clearCount <= 25 ? clearCount : 25` 规则。
## 11. 25 个积木件资源池替换
2026-05-03 根据新的参考图,把可消除物体替换为 25 个积木件类型,并调整本局类型抽取规则。
编码口径:
1. 默认 `visualKey` 资源池改为 25 个积木件,覆盖长条、短条、2x2、2x3、2x4、1x1、光板、斜坡、圆柱、透明圆环、拱门和锥形件等差异化模型。
2. 前端 3D 表现继续使用 Three.js 程序化几何体生成,不引入外部贴图或 GLB;托盘和 2D 回退继续使用同一批 `visualKey` 的简化图标。
3. `clearCount <= 25` 时,本局从 25 个类型中按确定性随机顺序抽取 `clearCount` 种类型,不允许同局刷新重复类型。
4. `clearCount > 25` 时,本局最多使用 25 种类型,额外消除组在这 25 种中轮转复用;每种类型最终数量仍必须是 3 的倍数。
5. 该随机抽取只决定本局使用哪些类型和使用顺序,不改变物理堆叠、遮挡、边界、可点击判定、备选栏和胜负规则。
6. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套 `itemTypeCount = clearCount <= 25 ? clearCount : 25` 口径。
## 12. 五档体积规则
2026-05-03 追加可消除物模型大小规则,把每局可消除物按五档相对体积分配。
编码口径:
1. M 型作为标准体积 `1.00`
2. XL 型相对体积范围为 `1.60~2.30`,占本局可消除类型数的 `20%`
3. L 型相对体积范围为 `1.25~1.60`,占本局可消除类型数的 `30%`
4. M 型相对体积固定为 `1.00`,占本局可消除类型数的 `30%`
5. XS 型相对体积范围为 `0.65~0.85`,占本局可消除类型数的 `15%`
6. S 型相对体积范围为 `0.35~0.50`,占本局可消除类型数的 `5%`
7. 本局使用类型数仍按第 11 节计算,即 `clearCount <= 25 ? clearCount : 25`。比例遇到非整数时按最大余数补齐,确保五档数量之和等于本局使用类型数。
8. 体积档位分配绑定到本局选中的 `visualKey`,同一局内同一个颜色和造型只能有一个尺寸档位和一个半径;当 `clearCount > 25` 轮转复用类型时,复用的同一 `visualKey` 继续沿用同一尺寸。
9. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套五档体积分配口径。
@@ -202,10 +202,45 @@ Jenkins 可运行在 Windows 或其他机器上,本机 Windows 只作为人工
- Jenkins Job 参数不暴露真实节点名、IP 或带 IP 的标签。
- 生产机已作为独立 Linux Jenkins agent 接入,节点名使用脱敏名称 `genarrative-release-deploy-01`,调度标签只使用 `linux``genarrative-release-deploy`
- 生产机真实连接地址只允许保存在 Jenkins 节点 SSH launcher 的 `host` 字段中,不能写入节点名、调度标签、Job 参数默认值或文档推荐命令
- 生产机 agent 启动方式统一改为 inbound agent + systemd 自守护,不再依赖 Jenkins controller 通过 SSH launcher 长期拉起。SSH 只作为首次登录和安装 systemd 服务的运维通道
- 生产机真实连接地址只允许保存在 Jenkins 节点连接配置或人工运维 SSH 配置中,不能写入节点名、调度标签、Job 参数默认值或文档推荐命令。
- 发布 Job 通过 `DEPLOY_TARGET` 选择逻辑部署目标,再在 Jenkinsfile 内部映射到 Linux-only 脱敏调度表达式:`development -> linux && genarrative-build``release -> linux && genarrative-release-deploy`
- 用途:服务器配置、发布静态网站、发布 `api-server`、发布 SpacetimeDB 模块、数据库导入导出、维护模式切换。
### Jenkins inbound agent 自恢复
发布 agent 必须由目标 Linux 机器主动连接 Jenkins controller,并由 systemd 托管:
- Jenkins 节点 Launch method 使用 inbound agent,优先启用 WebSocket。这样目标机只需要能访问 Jenkins Web 地址,不依赖 controller 每次 SSH 拉起 agent。
- 目标机安装 `deploy/systemd/jenkins-agent@.service``scripts/deploy/jenkins-inbound-agent-start.sh``scripts/deploy/install-jenkins-inbound-agent.sh`
- systemd 服务名采用 `jenkins-agent@<node-name>.service`,例如 `jenkins-agent@genarrative-release-deploy-01.service`
- systemd 自身 `WorkingDirectory` 保持 `/var/lib/jenkins/agent/<node-name>`Jenkins remoting `-workDir` 可继续使用旧 SSH agent 的 `/root/jenkins-agent`,避免迁移时 workspace 和缓存路径漂移。
- inbound secret 只能放在目标机 `/etc/jenkins-agent/<node-name>.secret` 或等价 Secret Text 注入位置,不能提交到 Git,也不能写入 Jenkinsfile 默认参数。
- systemd unit 使用 `Restart=always``RestartSec=10`;agent Java 进程退出、网络短断或机器重启后由 systemd 自动恢复,不需要人工盯着 Jenkins 页面手动重启。
- 当前 `Genarrative-Server-Provision` 仍负责 systemd、Nginx、`/opt/genarrative``/etc/genarrative` 等特权写入,因此 inbound agent 默认仍按现有 root 执行口径迁移。若后续改为 `jenkins` 用户运行 agent,必须先把生产流水线需要的特权命令收敛为精确 `NOPASSWD` sudoers 白名单。
如果 Jenkins controller 只运行在本地 Windows,不直接对目标机暴露公网地址,需要在本地控制机启动 `scripts/deploy/jenkins-agent-reverse-tunnel.ps1`。该脚本通过同一条 SSH 会话把远端 `127.0.0.1:18080` 转到本地 Jenkins Web `127.0.0.1:8080`,把远端 `127.0.0.1:50000` 转到本地 Jenkins inbound TCP agent port `127.0.0.1:50000`,并在隧道断开后自动重试。此时远端 agent 的 `JENKINS_URL` 固定写 `http://127.0.0.1:18080/`,不写本地 Windows 的 `127.0.0.1:8080`
本地反向隧道脚本不内置目标机地址;注册 Windows 计划任务时必须显式传入 `-RemoteHost <release-agent-host>`,真实 IP 或主机名只保存在本地计划任务配置中,不提交到 Git。
当 Jenkins controller 以本地 Windows `java -jar jenkins.war` 方式运行时,使用 `scripts/deploy/jenkins-local-controller-watchdog.ps1` 作为本地守护脚本。该脚本只保存本机 Java、`jenkins.war``JENKINS_HOME` 和端口路径,不保存 Jenkins 账号、密码、token 或 agent secret;注册 Windows 计划任务后,脚本会在登录后检查 `8080` 是否已有 Jenkins 监听,若已有则监控现有 PID,若进程退出或端口空闲则重新启动 Jenkins,并固定 `--agentPort=50000` 供远端 inbound agent 连接。
首次迁移示例:
```bash
sudo install -m 0600 /tmp/genarrative-release-deploy-01.secret /etc/jenkins-agent/genarrative-release-deploy-01.secret
sudo scripts/deploy/install-jenkins-inbound-agent.sh \
--agent-name genarrative-release-deploy-01 \
--jenkins-url http://127.0.0.1:18080/ \
--secret-file /etc/jenkins-agent/genarrative-release-deploy-01.secret \
--workdir /root/jenkins-agent \
--java-bin /usr/bin/java
sudo systemctl status jenkins-agent@genarrative-release-deploy-01.service --no-pager -l
journalctl -u jenkins-agent@genarrative-release-deploy-01.service -f
```
如果 Jenkins controller 暂时仍配置为 SSH launcher,只能作为过渡方案使用:需要把 SSH launch timeout 拉长、增加 retry 和 retry wait、固定 Java 路径,并确认 `ssh user@host 'java -version'` 稳定返回。最终仍要切到 inbound + systemd,避免 SSH 连接卡住时阻塞发布队列。
### Git 仓库访问
Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置:
@@ -538,6 +573,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
- [x] `deploy/systemd/spacetimedb.service`
- [x] `deploy/systemd/genarrative-api.service`
- [x] `deploy/systemd/jenkins-agent@.service`
- [x] `deploy/nginx/genarrative.conf`
- [x] `deploy/nginx/genarrative-dev-http.conf`
- [x] `deploy/nginx/snippets/genarrative-maintenance.conf`
@@ -545,6 +581,10 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
- [x] `scripts/deploy/maintenance-on.sh`
- [x] `scripts/deploy/maintenance-off.sh`
- [x] `scripts/deploy/maintenance-status.sh`
- [x] `scripts/deploy/jenkins-local-controller-watchdog.ps1`
- [x] `scripts/deploy/jenkins-agent-reverse-tunnel.ps1`
- [x] `scripts/deploy/jenkins-inbound-agent-start.sh`
- [x] `scripts/deploy/install-jenkins-inbound-agent.sh`
- [x] `scripts/build-production-release.sh`
- [x] `scripts/jenkins-checkout-source.sh`
- [x] `scripts/deploy/production-web-deploy.sh`
@@ -0,0 +1,78 @@
# 个人任务与埋点系统技术方案
更新时间:`2026-05-03`
## 1. 目标
本轮新增一套可配置的个人任务系统,并补齐任务依赖的埋点统计能力。首个任务为“每日登录”,奖励 `10` 光点,入口放在“我的”页签;后台可修改任务配置。
## 2. 核心边界
- 埋点原始事实写入 `tracking_event`,这是实际存在的 SpacetimeDB 表。
- 聚合投影写入 `tracking_daily_stat`,这也是后端维护的真实表,不是 view。
- 任务配置写入 `profile_task_config`,默认配置包含 `daily_login`,后台修改后不得被默认初始化覆盖。
- 任务进度写入 `profile_task_progress`,用于任务中心快速读取状态。
- 领奖记录写入 `profile_task_reward_claim`,与钱包流水 `profile_wallet_ledger` 同事务写入。
- “星光”奖励复用现有“光点”钱包,不新增第二种货币。
## 3. 埋点分层
| 层级 | scope_kind | scope_id 口径 |
| --- | --- | --- |
| 整站 | `site` | 固定为 `site` 或站点分区 key |
| 作品 | `work` | 作品 profile_id / work_id |
| 模块 | `module` | 模块 key,例如 `profile``puzzle` |
| 用户 | `user` | 用户 id |
每条埋点可同时记录 `user_id``owner_user_id``profile_id``module_key``metadata_json`。任务首版只依赖用户层 `daily_login`,表结构先保留四层统计能力。
## 4. 日期桶
任务统计使用北京时间自然日:`day_key = floor((occurred_at_micros + 8h) / 1d)`
这样存储仍是 UTC 时间戳,日切规则固定为业务口径,不依赖服务器本地时区。`tracking_event.occurred_at` 保存精确发生时间,`tracking_daily_stat.day_key` 只承担聚合桶职责。
## 5. 首版任务
| 字段 | 默认值 |
| --- | --- |
| task_id | `daily_login` |
| title | `每日登录` |
| event_key | `daily_login` |
| cycle | `daily` |
| threshold | `1` |
| reward_points | `10` |
| enabled | `true` |
用户打开任务中心时,后端会幂等记录当日 `daily_login` 埋点并刷新任务进度。用户点击领取时,后端校验当日进度、领奖记录和配置状态,然后同事务写入领奖记录与钱包流水。
后台任务配置页的 `Event Key` 使用可搜索下拉控件,选项来自前端后台的埋点定义注册表。当前注册表默认包含 `daily_login`,展示中文名称和备注;后续新增任务依赖的埋点时,应先补充注册表,再开放运营配置。
## 6. 接口
### 用户侧
- `GET /api/profile/tasks`:读取任务中心,同时记录当日登录埋点。
- `POST /api/profile/tasks/{task_id}/claim`:领取任务奖励。
### 后台侧
- `GET /admin/api/profile/tasks`:读取任务配置列表。
- `POST /admin/api/profile/tasks`:新增或更新任务配置。
- `POST /admin/api/profile/tasks/disable`:停用任务配置。
后台任务配置页进入时从 `profile_task_config` 对应的列表接口读取已有配置,点击列表项回填表单后仍通过同一个 upsert 接口修改原配置。最近一次保存结果可以保留为会话态提示,但不得作为任务配置列表的唯一来源。
## 7. 查询文档边界
- `docs/tracking/` 只存放具体埋点与埋点聚合查询,例如 `tracking_event``tracking_daily_stat` 的站点/作品/模块/用户查询。
- `docs/operations/` 存放运营核查查询,例如任务进度、领奖记录、钱包流水对账。
不要把任务进度、领奖记录或钱包对账查询塞进 `docs/tracking/`,它们不是埋点系统本身。
## 8. 验收
1. `profile_task_config` 默认存在 `daily_login`,后台可修改奖励、阈值、标题和启用状态。
2. “我的”页可以打开每日任务面板,登录后任务可领取 `10` 光点。
3. 重复打开任务中心不会重复增加领取资格,重复领奖不会重复发放。
4. 表目录、迁移白名单、Rust/TypeScript 契约和前端入口同步更新。
+1
View File
@@ -4,6 +4,7 @@
## 文档列表
- [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](./PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md):冻结个人任务与埋点系统首版方案,明确 `tracking_event``tracking_daily_stat``profile_task_config`、任务进度、领奖记录和光点钱包流水的边界。
- [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md):冻结单机生产部署目标,从旧一体化启动脚本切到 Nginx、systemd 托管 SpacetimeDB 与 Rust `api-server`,并记录生产 Jenkins 流水线拆分计划和首批部署骨架。
- [PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md](./PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。
- [RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md](./RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md):记录 `agent-foundation-*-dossier-batch-*` 无搜索 Responses 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。

Some files were not shown because too many files have changed in this diff Show More