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 全部通过
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import {
|
||
AdminActionRow,
|
||
AdminButton,
|
||
AdminField,
|
||
AdminListPanel,
|
||
AdminPage,
|
||
AdminPageHeading,
|
||
AdminPagination,
|
||
AdminPanel,
|
||
formatAdminDateTime,
|
||
Modal,
|
||
} from '@genarrative/shared/components';
|
||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||
|
||
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
|
||
import type {
|
||
AdminAgcTrackingEventEntry,
|
||
AdminAgcTrackingEventListResponse,
|
||
AdminAgcTrackingEventQuery,
|
||
} from '../api/adminApiTypes';
|
||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||
import { handlePageError } from './pageUtils';
|
||
|
||
const eventLabels: Record<string, string> = {
|
||
editor_session_start: '编辑器会话开始',
|
||
editor_session_end: '编辑器会话结束',
|
||
editor_focus_start: '编辑器获得焦点',
|
||
editor_focus_end: '编辑器失去焦点',
|
||
project_create_success: '项目创建成功',
|
||
project_open: '打开项目',
|
||
creative_task_submit: '首次提交创作目标',
|
||
agent_run_completed: 'Agent 运行完成',
|
||
agent_run_failed: 'Agent 运行失败',
|
||
project_revision_created: '项目产生修改',
|
||
preview_ready: '预览就绪',
|
||
project_save: '保存项目',
|
||
};
|
||
|
||
const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
|
||
eventId: '事件 ID',
|
||
schemaVersion: '事件版本',
|
||
eventName: '事件类型',
|
||
eventTime: '发生时间',
|
||
userId: '用户 ID',
|
||
editorSessionId: '编辑器会话 ID',
|
||
projectId: '项目 ID',
|
||
creativeTaskId: '创作目标 ID',
|
||
agentRunId: 'Agent run ID',
|
||
agentTurnId: 'Agent turn ID',
|
||
status: '结果',
|
||
errorCode: '错误码',
|
||
source: '来源',
|
||
clientVersion: '客户端版本',
|
||
properties: '事件属性',
|
||
batchId: '批次 ID',
|
||
receivedAt: '入库时间',
|
||
};
|
||
|
||
export function AdminAgcTrackingPage({
|
||
token,
|
||
onUnauthorized,
|
||
}: {
|
||
token: string;
|
||
onUnauthorized: (message?: string) => void;
|
||
}) {
|
||
const [filters, setFilters] = useState({
|
||
userId: '',
|
||
projectId: '',
|
||
eventName: '',
|
||
startTime: '',
|
||
endTime: '',
|
||
});
|
||
const [query, setQuery] = useState<AdminAgcTrackingEventQuery>({});
|
||
const [cursors, setCursors] = useState<Array<string | undefined>>([
|
||
undefined,
|
||
]);
|
||
const [page, setPage] = useState(0);
|
||
const [refresh, setRefresh] = useState(0);
|
||
const [entries, setEntries] = useState<AdminAgcTrackingEventEntry[]>([]);
|
||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [detail, setDetail] = useState<AdminAgcTrackingEventEntry | null>(null);
|
||
const cursor = cursors[page];
|
||
// 首页没有入站游标,返回首页时保留原快照,刷新才获取新数据。
|
||
const firstPage = useRef<{
|
||
token: string;
|
||
query: AdminAgcTrackingEventQuery;
|
||
response: AdminAgcTrackingEventListResponse;
|
||
} | null>(null);
|
||
|
||
useEffect(() => {
|
||
const saved = firstPage.current;
|
||
if (!cursor && saved?.token === token && saved.query === query) {
|
||
setEntries(saved.response.entries ?? []);
|
||
setNextCursor(saved.response.nextCursor ?? null);
|
||
setError('');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
let active = true;
|
||
setLoading(true);
|
||
setError('');
|
||
setEntries([]);
|
||
setNextCursor(null);
|
||
void listAdminAgcTrackingEvents(token, { ...query, cursor, limit: 50 })
|
||
.then((response) => {
|
||
if (!active) return;
|
||
if (!cursor) firstPage.current = { token, query, response };
|
||
// 接口形状异常时退回空列表,避免整页渲染因 undefined.length 崩掉。
|
||
setEntries(response.entries ?? []);
|
||
setNextCursor(response.nextCursor ?? null);
|
||
})
|
||
.catch((failure: unknown) => {
|
||
if (active) handlePageError(failure, onUnauthorized, setError);
|
||
})
|
||
.finally(() => {
|
||
if (active) setLoading(false);
|
||
});
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [token, query, cursor, refresh, onUnauthorized]);
|
||
|
||
function resetPages() {
|
||
firstPage.current = null;
|
||
setCursors([undefined]);
|
||
setPage(0);
|
||
setDetail(null);
|
||
}
|
||
|
||
function search(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (
|
||
filters.startTime &&
|
||
filters.endTime &&
|
||
new Date(filters.startTime) >= new Date(filters.endTime)
|
||
) {
|
||
setError('结束时间必须晚于开始时间');
|
||
return;
|
||
}
|
||
resetPages();
|
||
setQuery({
|
||
userId: filters.userId.trim(),
|
||
projectId: filters.projectId.trim(),
|
||
eventName: filters.eventName,
|
||
startTime: filters.startTime
|
||
? new Date(filters.startTime).toISOString()
|
||
: undefined,
|
||
endTime: filters.endTime
|
||
? new Date(filters.endTime).toISOString()
|
||
: undefined,
|
||
});
|
||
}
|
||
|
||
function related(
|
||
key: 'creativeTaskId' | 'agentRunId' | 'clientVersion',
|
||
value: string,
|
||
) {
|
||
resetPages();
|
||
setFilters({
|
||
userId: '',
|
||
projectId: '',
|
||
eventName: '',
|
||
startTime: '',
|
||
endTime: '',
|
||
});
|
||
setQuery({ [key]: value });
|
||
}
|
||
|
||
return (
|
||
<AdminPage wide>
|
||
<AdminPageHeading
|
||
title="客户端埋点"
|
||
description="按发生时间倒序展示"
|
||
actions={
|
||
<AdminButton
|
||
variant="secondary"
|
||
type="button"
|
||
disabled={loading}
|
||
onClick={() => {
|
||
resetPages();
|
||
setRefresh((value) => value + 1);
|
||
}}
|
||
>
|
||
刷新
|
||
</AdminButton>
|
||
}
|
||
/>
|
||
<AdminPanel as="form" onSubmit={search}>
|
||
<div className="admin-filter-grid">
|
||
{(['userId', 'projectId'] as const).map((key) => (
|
||
<AdminField label={fieldLabels[key]} key={key}>
|
||
<input
|
||
value={filters[key]}
|
||
onChange={(event) =>
|
||
setFilters({
|
||
...filters,
|
||
[key]: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
</AdminField>
|
||
))}
|
||
<AdminField label="事件类型">
|
||
<select
|
||
value={filters.eventName}
|
||
onChange={(event) =>
|
||
setFilters({
|
||
...filters,
|
||
eventName: event.target.value,
|
||
})
|
||
}
|
||
>
|
||
<option value="">全部</option>
|
||
{Object.entries(eventLabels).map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</AdminField>
|
||
{(['startTime', 'endTime'] as const).map((key) => (
|
||
<AdminField
|
||
label={
|
||
key === 'startTime'
|
||
? '发生时间起点(含)'
|
||
: '发生时间终点(不含)'
|
||
}
|
||
key={key}
|
||
>
|
||
<input
|
||
type="datetime-local"
|
||
value={filters[key]}
|
||
onChange={(event) =>
|
||
setFilters({
|
||
...filters,
|
||
[key]: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
</AdminField>
|
||
))}
|
||
</div>
|
||
<AdminActionRow>
|
||
<AdminButton variant="primary" type="submit" disabled={loading}>
|
||
查询
|
||
</AdminButton>
|
||
</AdminActionRow>
|
||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
|
||
.filter((key) => query[key])
|
||
.map((key) => (
|
||
<p key={key}>
|
||
{fieldLabels[key]}:{query[key]}(重新查询可清除)
|
||
</p>
|
||
))}
|
||
</AdminPanel>
|
||
{error ? (
|
||
<p role="alert" className="admin-error-message">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
<AdminListPanel
|
||
busy={loading}
|
||
busyRole="status"
|
||
busyText="加载中…"
|
||
busyVariant="line"
|
||
emptyText="暂无客户端埋点数据"
|
||
emptyVariant="text"
|
||
rows={entries}
|
||
showEmpty={!loading && !error && entries.length === 0}
|
||
pagination={
|
||
<AdminPagination
|
||
ariaLabel="客户端埋点分页"
|
||
info={<>第 {page + 1} 页</>}
|
||
busy={loading}
|
||
previous={{
|
||
disabled: page === 0,
|
||
onClick: () => setPage((value) => value - 1),
|
||
}}
|
||
next={{
|
||
disabled: !nextCursor,
|
||
onClick: () => {
|
||
if (!nextCursor) return;
|
||
setCursors([...cursors.slice(0, page + 1), nextCursor]);
|
||
setPage(page + 1);
|
||
},
|
||
}}
|
||
/>
|
||
}
|
||
columns={[
|
||
{ key: 'receivedAt', label: '入库时间' },
|
||
{ key: 'eventTime', label: '发生时间' },
|
||
{ key: 'userId', label: '用户' },
|
||
{ key: 'eventName', label: '事件名称' },
|
||
{ key: 'projectId', label: '项目' },
|
||
{ key: 'source', label: '来源' },
|
||
{ key: 'status', label: '结果' },
|
||
{ key: 'clientVersion', label: '客户端版本' },
|
||
{ key: 'actions', label: '详情' },
|
||
]}
|
||
renderRow={(entry) => (
|
||
<tr key={entry.eventId}>
|
||
<td>{formatAdminDateTime(entry.receivedAt)}</td>
|
||
<td>{formatAdminDateTime(entry.eventTime)}</td>
|
||
<td>
|
||
{entry.userId}
|
||
<AdminUserReferenceButton
|
||
token={token}
|
||
userId={entry.userId}
|
||
onUnauthorized={onUnauthorized}
|
||
/>
|
||
</td>
|
||
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
|
||
<td>{entry.projectId ?? '—'}</td>
|
||
<td>{entry.source}</td>
|
||
<td>{entry.status ?? '—'}</td>
|
||
<td>{entry.clientVersion}</td>
|
||
<td>
|
||
<AdminButton
|
||
variant="ghost"
|
||
type="button"
|
||
onClick={() => setDetail(entry)}
|
||
>
|
||
查看详情
|
||
</AdminButton>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
size="wide"
|
||
/>
|
||
{detail ? (
|
||
<Modal
|
||
open
|
||
title="客户端埋点详情"
|
||
closeLabel="关闭详情"
|
||
onClose={() => setDetail(null)}
|
||
className="genarrative-ui"
|
||
>
|
||
<dl>
|
||
{(
|
||
Object.keys(fieldLabels) as Array<
|
||
keyof AdminAgcTrackingEventEntry
|
||
>
|
||
)
|
||
.filter((key) => key !== 'properties')
|
||
.map((key) => (
|
||
<div key={key}>
|
||
<dt>{fieldLabels[key]}</dt>
|
||
<dd
|
||
style={{
|
||
overflowWrap: 'anywhere',
|
||
}}
|
||
>
|
||
{detail[key] == null ? '—' : String(detail[key])}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
<AdminActionRow>
|
||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
|
||
(key) =>
|
||
detail[key] ? (
|
||
<AdminButton
|
||
variant="secondary"
|
||
type="button"
|
||
key={key}
|
||
onClick={() => related(key, detail[key]!)}
|
||
>
|
||
查询同一{fieldLabels[key]}
|
||
</AdminButton>
|
||
) : null,
|
||
)}
|
||
</AdminActionRow>
|
||
<h3>事件属性</h3>
|
||
<pre
|
||
style={{
|
||
whiteSpace: 'pre-wrap',
|
||
overflowWrap: 'anywhere',
|
||
}}
|
||
>
|
||
{JSON.stringify(detail.properties, null, 2)}
|
||
</pre>
|
||
</Modal>
|
||
) : null}
|
||
</AdminPage>
|
||
);
|
||
}
|