8d83b14ae5
新增客户端每十五分钟上传及入库确认后批次清理 新增私有事件表、原子幂等入库及鉴权查询接口 新增后台客户端埋点明细、筛选、分页与权限 补充定向测试、真实数据库联调脚本及工程验收文档
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
import { 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: '入库时间',
|
||
};
|
||
|
||
function formatTime(value: string) {
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
||
}
|
||
|
||
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);
|
||
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 };
|
||
setEntries(response.entries);
|
||
setNextCursor(response.nextCursor);
|
||
})
|
||
.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 (
|
||
<section className="admin-page admin-page-wide">
|
||
<div className="admin-page-heading">
|
||
<div>
|
||
<h2>客户端埋点</h2>
|
||
<p>按入库时间倒序展示</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="admin-secondary-button"
|
||
disabled={loading}
|
||
onClick={() => {
|
||
resetPages();
|
||
setRefresh((value) => value + 1);
|
||
}}
|
||
>
|
||
刷新
|
||
</button>
|
||
</div>
|
||
<form className="admin-panel admin-form" onSubmit={search}>
|
||
<div className="admin-filter-grid">
|
||
{(['userId', 'projectId'] as const).map((key) => (
|
||
<label key={key} className="admin-field">
|
||
<span>{fieldLabels[key]}</span>
|
||
<input
|
||
value={filters[key]}
|
||
onChange={(event) =>
|
||
setFilters({ ...filters, [key]: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
))}
|
||
<label className="admin-field">
|
||
<span>事件类型</span>
|
||
<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>
|
||
</label>
|
||
{(['startTime', 'endTime'] as const).map((key) => (
|
||
<label key={key} className="admin-field">
|
||
<span>
|
||
{key === 'startTime'
|
||
? '发生时间起点(含)'
|
||
: '发生时间终点(不含)'}
|
||
</span>
|
||
<input
|
||
type="datetime-local"
|
||
value={filters[key]}
|
||
onChange={(event) =>
|
||
setFilters({ ...filters, [key]: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div className="admin-action-row">
|
||
<button
|
||
type="submit"
|
||
className="admin-primary-button"
|
||
disabled={loading}
|
||
>
|
||
查询
|
||
</button>
|
||
</div>
|
||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
|
||
.filter((key) => query[key])
|
||
.map((key) => (
|
||
<p key={key}>
|
||
{fieldLabels[key]}:{query[key]}(重新查询可清除)
|
||
</p>
|
||
))}
|
||
</form>
|
||
{error ? (
|
||
<p role="alert" className="admin-error-message">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
<div className="admin-panel">
|
||
<div className="admin-table-wrap">
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr>
|
||
{[
|
||
'入库时间',
|
||
'发生时间',
|
||
'用户',
|
||
'事件名称',
|
||
'项目',
|
||
'来源',
|
||
'结果',
|
||
'客户端版本',
|
||
'详情',
|
||
].map((label) => (
|
||
<th key={label}>{label}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{entries.map((entry) => (
|
||
<tr key={entry.eventId}>
|
||
<td>{formatTime(entry.receivedAt)}</td>
|
||
<td>{formatTime(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>
|
||
<button
|
||
type="button"
|
||
className="admin-ghost-button"
|
||
onClick={() => setDetail(entry)}
|
||
>
|
||
查看详情
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{loading ? (
|
||
<p role="status">加载中…</p>
|
||
) : !error && entries.length === 0 ? (
|
||
<p>暂无客户端埋点数据</p>
|
||
) : null}
|
||
<div className="admin-action-row">
|
||
<button
|
||
type="button"
|
||
className="admin-secondary-button"
|
||
disabled={loading || page === 0}
|
||
onClick={() => setPage((value) => value - 1)}
|
||
>
|
||
上一页
|
||
</button>
|
||
<span>第 {page + 1} 页</span>
|
||
<button
|
||
type="button"
|
||
className="admin-secondary-button"
|
||
disabled={loading || !nextCursor}
|
||
onClick={() => {
|
||
if (!nextCursor) return;
|
||
setCursors([...cursors.slice(0, page + 1), nextCursor]);
|
||
setPage(page + 1);
|
||
}}
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{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>
|
||
<div className="admin-action-row">
|
||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
|
||
(key) =>
|
||
detail[key] ? (
|
||
<button
|
||
type="button"
|
||
key={key}
|
||
className="admin-secondary-button"
|
||
onClick={() => related(key, detail[key]!)}
|
||
>
|
||
查询同一{fieldLabels[key]}
|
||
</button>
|
||
) : null,
|
||
)}
|
||
</div>
|
||
<h3>事件属性</h3>
|
||
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||
{JSON.stringify(detail.properties, null, 2)}
|
||
</pre>
|
||
</Modal>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|