feat: add admin tracking events export
This commit is contained in:
@@ -6,6 +6,8 @@ import type {
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminTrackingEventListQuery,
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
AdminUpsertProfileTaskConfigRequest,
|
||||
@@ -135,6 +137,16 @@ export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
export function listAdminTrackingEvents(
|
||||
token: string,
|
||||
query: AdminTrackingEventListQuery = {},
|
||||
) {
|
||||
return request<AdminTrackingEventListResponse>(
|
||||
`/admin/api/tracking/events${buildQueryString(query)}`,
|
||||
{token},
|
||||
);
|
||||
}
|
||||
|
||||
export function listProfileRedeemCodes(token: string) {
|
||||
return request<ProfileRedeemCodeAdminListResponse>(
|
||||
'/admin/api/profile/redeem-codes',
|
||||
@@ -232,6 +244,30 @@ function buildRequestUrl(path: string) {
|
||||
return `${ADMIN_API_BASE_URL}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function buildQueryString(query: AdminTrackingEventListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'eventKey', query.eventKey);
|
||||
appendQueryParam(params, 'userId', query.userId);
|
||||
appendQueryParam(params, 'scopeKind', query.scopeKind);
|
||||
appendQueryParam(params, 'scopeId', query.scopeId);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function appendQueryParam(
|
||||
params: URLSearchParams,
|
||||
key: string,
|
||||
value: string | null | undefined,
|
||||
) {
|
||||
const trimmed = value?.trim();
|
||||
if (trimmed) {
|
||||
params.set(key, trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonResponse(responseText: string): unknown {
|
||||
if (!responseText.trim()) {
|
||||
return null;
|
||||
|
||||
@@ -109,6 +109,14 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
|
||||
export type ProfileTaskCycle = 'daily';
|
||||
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
|
||||
|
||||
export interface AdminTrackingEventListQuery {
|
||||
eventKey?: string;
|
||||
userId?: string;
|
||||
scopeKind?: TrackingScopeKind | '';
|
||||
scopeId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
@@ -199,3 +207,22 @@ export interface ProfileTaskConfigAdminResponse {
|
||||
export interface ProfileTaskConfigAdminListResponse {
|
||||
entries: ProfileTaskConfigAdminResponse[];
|
||||
}
|
||||
|
||||
export interface AdminTrackingEventEntryPayload {
|
||||
eventId: string;
|
||||
eventKey: string;
|
||||
eventTitle: string;
|
||||
scopeKind: TrackingScopeKind | string;
|
||||
scopeId: string;
|
||||
dayKey: number;
|
||||
userId?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
profileId?: string | null;
|
||||
moduleKey?: string | null;
|
||||
metadataJson: string;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface AdminTrackingEventListResponse {
|
||||
entries: AdminTrackingEventEntryPayload[];
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
|
||||
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
|
||||
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
|
||||
import {AdminShell} from './AdminShell';
|
||||
import type {AdminRouteId} from './adminRoutes';
|
||||
import {resolveAdminRoute, routeHash} from './adminRoutes';
|
||||
@@ -162,6 +163,12 @@ export function AdminApp() {
|
||||
{routeId === 'debug' ? (
|
||||
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{routeId === 'tracking' ? (
|
||||
<AdminTrackingEventsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'redeem' ? (
|
||||
<AdminRedeemCodePage
|
||||
result={redeemResult}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
ListChecks,
|
||||
Table2,
|
||||
TicketCheck,
|
||||
TicketPercent,
|
||||
} from 'lucide-react';
|
||||
@@ -24,6 +25,7 @@ interface AdminShellProps {
|
||||
const routeIcons = {
|
||||
overview: LayoutDashboard,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
redeem: TicketPercent,
|
||||
invite: TicketCheck,
|
||||
tasks: ListChecks,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite' | 'tasks';
|
||||
export type AdminRouteId = 'overview' | 'debug' | 'tracking' | 'redeem' | 'invite' | 'tasks';
|
||||
|
||||
export interface AdminRouteDefinition {
|
||||
id: AdminRouteId;
|
||||
@@ -9,6 +9,7 @@ export interface AdminRouteDefinition {
|
||||
export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'overview', label: '总览', hash: '#overview'},
|
||||
{id: 'debug', label: 'API 调试', hash: '#debug'},
|
||||
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
|
||||
{id: 'redeem', label: '兑换码', hash: '#redeem'},
|
||||
{id: 'invite', label: '邀请码', hash: '#invite'},
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
|
||||
407
apps/admin-web/src/pages/AdminTrackingEventsPage.tsx
Normal file
407
apps/admin-web/src/pages/AdminTrackingEventsPage.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import {Download, Eye, RefreshCcw, Search, X} from 'lucide-react';
|
||||
import {FormEvent, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {listAdminTrackingEvents} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminTrackingEventEntryPayload,
|
||||
TrackingScopeKind,
|
||||
} from '../api/adminApiTypes';
|
||||
import {
|
||||
filterAdminTrackingEventDefinitions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import {handlePageError} from './pageUtils';
|
||||
|
||||
interface AdminTrackingEventsPageProps {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
}
|
||||
|
||||
const scopeKindOptions: Array<{value: TrackingScopeKind | ''; label: string}> = [
|
||||
{value: '', label: '全部'},
|
||||
{value: 'site', label: 'site'},
|
||||
{value: 'work', label: 'work'},
|
||||
{value: 'module', label: 'module'},
|
||||
{value: 'user', label: 'user'},
|
||||
];
|
||||
|
||||
const exportColumns: Array<{
|
||||
key: keyof AdminTrackingEventEntryPayload;
|
||||
label: string;
|
||||
}> = [
|
||||
{key: 'eventId', label: '事件 ID'},
|
||||
{key: 'eventKey', label: 'Event Key'},
|
||||
{key: 'eventTitle', label: '事件名称'},
|
||||
{key: 'scopeKind', label: 'Scope Kind'},
|
||||
{key: 'scopeId', label: 'Scope ID'},
|
||||
{key: 'dayKey', label: 'Day Key'},
|
||||
{key: 'userId', label: 'User ID'},
|
||||
{key: 'ownerUserId', label: 'Owner User ID'},
|
||||
{key: 'profileId', label: 'Profile ID'},
|
||||
{key: 'moduleKey', label: 'Module Key'},
|
||||
{key: 'metadataJson', label: 'Metadata JSON'},
|
||||
{key: 'occurredAt', label: '发生时间'},
|
||||
];
|
||||
|
||||
export function AdminTrackingEventsPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminTrackingEventsPageProps) {
|
||||
const [entries, setEntries] = useState<AdminTrackingEventEntryPayload[]>([]);
|
||||
const [eventKey, setEventKey] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
const [scopeKind, setScopeKind] = useState<TrackingScopeKind | ''>('');
|
||||
const [scopeId, setScopeId] = useState('');
|
||||
const [limit, setLimit] = useState('200');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [detailEntry, setDetailEntry] =
|
||||
useState<AdminTrackingEventEntryPayload | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTrackingEvents();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const filteredEventDefinitions = useMemo(
|
||||
() => filterAdminTrackingEventDefinitions(eventKey),
|
||||
[eventKey],
|
||||
);
|
||||
|
||||
async function refreshTrackingEvents() {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await listAdminTrackingEvents(token, {
|
||||
eventKey,
|
||||
userId,
|
||||
scopeKind,
|
||||
scopeId,
|
||||
limit: parseLimit(limit),
|
||||
});
|
||||
setEntries(response.entries);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void refreshTrackingEvents();
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (!entries.length) {
|
||||
setErrorMessage('当前没有可导出的埋点数据');
|
||||
return;
|
||||
}
|
||||
exportTrackingEventsAsExcel(entries);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>埋点数据</h2>
|
||||
<p>原始事件明细</p>
|
||||
</div>
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshTrackingEvents}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={!entries.length}
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download size={17} aria-hidden="true" />
|
||||
<span>导出 Excel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="admin-panel admin-form" onSubmit={handleSearch}>
|
||||
<div className="admin-filter-grid">
|
||||
<label className="admin-field">
|
||||
<span>Event Key</span>
|
||||
<input
|
||||
list="admin-tracking-event-keys"
|
||||
placeholder="全部"
|
||||
value={eventKey}
|
||||
onChange={(event) => setEventKey(event.target.value)}
|
||||
/>
|
||||
<datalist id="admin-tracking-event-keys">
|
||||
{filteredEventDefinitions.map((definition) => (
|
||||
<option key={definition.key} value={definition.key}>
|
||||
{definition.title}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>User ID</span>
|
||||
<input
|
||||
placeholder="全部"
|
||||
value={userId}
|
||||
onChange={(event) => setUserId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Scope Kind</span>
|
||||
<select
|
||||
value={scopeKind}
|
||||
onChange={(event) =>
|
||||
setScopeKind(event.target.value as TrackingScopeKind | '')
|
||||
}
|
||||
>
|
||||
{scopeKindOptions.map((option) => (
|
||||
<option key={option.value || 'all'} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Scope ID</span>
|
||||
<input
|
||||
placeholder="全部"
|
||||
value={scopeId}
|
||||
onChange={(event) => setScopeId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>条数</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={limit}
|
||||
onChange={(event) => setLimit(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className="admin-secondary-button" disabled={isLoading} type="submit">
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '查询中' : '查询'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>事件明细</h3>
|
||||
<span>{entries.length} 条</span>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>事件</th>
|
||||
<th>Scope</th>
|
||||
<th>用户</th>
|
||||
<th>归属</th>
|
||||
<th>Metadata</th>
|
||||
<th>时间</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length ? (
|
||||
entries.map((entry) => (
|
||||
<tr key={entry.eventId}>
|
||||
<td>
|
||||
<strong>{resolveEventTitle(entry)}</strong>
|
||||
<small>{entry.eventKey}</small>
|
||||
<small>{entry.eventId}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span className="admin-status admin-status-pending">
|
||||
{entry.scopeKind}
|
||||
</span>
|
||||
<small>{entry.scopeId || '-'}</small>
|
||||
<small>dayKey: {entry.dayKey}</small>
|
||||
</td>
|
||||
<td>
|
||||
{entry.userId || '-'}
|
||||
<small>owner: {entry.ownerUserId || '-'}</small>
|
||||
</td>
|
||||
<td>
|
||||
{entry.profileId || '-'}
|
||||
<small>module: {entry.moduleKey || '-'}</small>
|
||||
</td>
|
||||
<td>
|
||||
<pre className="admin-json-preview">
|
||||
{formatMetadataJson(entry.metadataJson)}
|
||||
</pre>
|
||||
</td>
|
||||
<td>{entry.occurredAt || '-'}</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
type="button"
|
||||
onClick={() => setDetailEntry(entry)}
|
||||
>
|
||||
<Eye size={16} aria-hidden="true" />
|
||||
<span>详情</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7}>{isLoading ? '正在加载' : '暂无数据'}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailEntry ? (
|
||||
<TrackingEventDetailPanel
|
||||
entry={detailEntry}
|
||||
onClose={() => setDetailEntry(null)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingEventDetailPanel({
|
||||
entry,
|
||||
onClose,
|
||||
}: {
|
||||
entry: AdminTrackingEventEntryPayload;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-confirm-backdrop" role="presentation">
|
||||
<section
|
||||
aria-label="埋点详情"
|
||||
className="admin-detail-panel"
|
||||
role="dialog"
|
||||
>
|
||||
<div className="admin-panel-heading">
|
||||
<h3>{resolveEventTitle(entry)}</h3>
|
||||
<button
|
||||
aria-label="关闭详情"
|
||||
className="admin-ghost-button"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<dl className="admin-info-list admin-detail-list">
|
||||
{exportColumns.map((column) => (
|
||||
<div key={column.key}>
|
||||
<dt>{column.label}</dt>
|
||||
<dd>
|
||||
{column.key === 'metadataJson' ? (
|
||||
<pre className="admin-code-block">
|
||||
{formatMetadataJson(entry.metadataJson)}
|
||||
</pre>
|
||||
) : (
|
||||
formatExportCell(entry[column.key]) || '-'
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseLimit(value: string) {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 200;
|
||||
}
|
||||
return Math.min(Math.max(parsed, 1), 1000);
|
||||
}
|
||||
|
||||
function resolveEventTitle(entry: AdminTrackingEventEntryPayload) {
|
||||
return (
|
||||
findAdminTrackingEventDefinition(entry.eventKey)?.title ||
|
||||
entry.eventTitle ||
|
||||
entry.eventKey
|
||||
);
|
||||
}
|
||||
|
||||
function formatMetadataJson(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '-';
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) {
|
||||
const tableRows = [
|
||||
exportColumns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join(''),
|
||||
...entries.map((entry) =>
|
||||
exportColumns
|
||||
.map((column) => `<td style="mso-number-format:'\\@';">${escapeHtml(formatExportCell(entry[column.key]))}</td>`)
|
||||
.join(''),
|
||||
),
|
||||
];
|
||||
const html = `\uFEFF<html><head><meta charset="UTF-8" /></head><body><table>${tableRows
|
||||
.map((row) => `<tr>${row}</tr>`)
|
||||
.join('')}</table></body></html>`;
|
||||
const blob = new Blob([html], {type: 'application/vnd.ms-excel;charset=utf-8'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `tracking-events-${buildTimestamp()}.xls`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function formatExportCell(value: unknown) {
|
||||
if (value === null || typeof value === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function buildTimestamp() {
|
||||
const now = new Date();
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return [
|
||||
now.getFullYear(),
|
||||
pad(now.getMonth() + 1),
|
||||
pad(now.getDate()),
|
||||
'-',
|
||||
pad(now.getHours()),
|
||||
pad(now.getMinutes()),
|
||||
pad(now.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
@@ -216,6 +216,10 @@ button:disabled {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-page-wide {
|
||||
max-width: 1480px;
|
||||
}
|
||||
|
||||
.admin-page-heading,
|
||||
.admin-panel-heading,
|
||||
.admin-subsection-heading {
|
||||
@@ -291,6 +295,20 @@ button:disabled {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr)) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
@@ -478,6 +496,23 @@ button:disabled {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.admin-detail-panel {
|
||||
display: grid;
|
||||
width: min(100%, 760px);
|
||||
max-height: min(90dvh, 760px);
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
border: 1px solid #d8e2e8;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 22px 60px rgba(23, 33, 43, 0.24);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.admin-detail-list .admin-code-block {
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.admin-confirm-warning {
|
||||
border: 1px solid #efc894;
|
||||
border-radius: 8px;
|
||||
@@ -602,6 +637,24 @@ button:disabled {
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.admin-table-wide {
|
||||
min-width: 1180px;
|
||||
}
|
||||
|
||||
.admin-json-preview {
|
||||
max-width: 360px;
|
||||
max-height: 160px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
color: #2f4550;
|
||||
font-family:
|
||||
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-status {
|
||||
display: inline-flex;
|
||||
max-width: 460px;
|
||||
@@ -757,7 +810,8 @@ button:disabled {
|
||||
.admin-overview-grid,
|
||||
.admin-two-column,
|
||||
.admin-two-column-wide,
|
||||
.admin-form-row {
|
||||
.admin-form-row,
|
||||
.admin-filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
170
docs/technical/ADMIN_TRACKING_EVENT_DETAIL_EXPORT_2026-05-07.md
Normal file
170
docs/technical/ADMIN_TRACKING_EVENT_DETAIL_EXPORT_2026-05-07.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 后台埋点数据明细与 Excel 导出方案
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 在百梦后台新增“埋点数据”页,展示每条埋点原始事件的详细字段,并支持导出为 Excel 可直接打开的表格文件。
|
||||
|
||||
**Architecture:** 后端继续由 `api-server` 作为后台 BFF,经 SpacetimeDB HTTP SQL 只读查询 `tracking_event`,不改变表结构和 reducer。前端在 `apps/admin-web` 中新增独立路由与页面,页面渲染后端返回的原始明细,并在浏览器侧导出 Excel 兼容的 `.xls` HTML 表格,避免新增依赖。
|
||||
|
||||
**Tech Stack:** Rust Axum、SpacetimeDB HTTP SQL、shared-contracts、React 19、TypeScript、Vite。
|
||||
|
||||
---
|
||||
|
||||
## 范围
|
||||
|
||||
本次只做后台只读能力:
|
||||
|
||||
- 展示 `tracking_event` 原始事件明细。
|
||||
- 每条埋点展示:事件 ID、Event Key、事件名称、Scope、Scope ID、Day Key、用户 ID、作品拥有者、Profile ID、模块、metadata、发生时间。
|
||||
- 支持按 Event Key、用户 ID、Scope Kind、Scope ID 筛选。
|
||||
- 支持导出当前筛选结果为 Excel 可打开文件。
|
||||
|
||||
不做:
|
||||
|
||||
- 不新增或修改 SpacetimeDB 表结构。
|
||||
- 不在后台写入或删除埋点。
|
||||
- 不把埋点聚合口径下沉到前端计算。
|
||||
|
||||
## 后端契约
|
||||
|
||||
新增接口:
|
||||
|
||||
```text
|
||||
GET /admin/api/tracking/events?eventKey=&userId=&scopeKind=&scopeId=&limit=
|
||||
```
|
||||
|
||||
鉴权:复用后台 `require_admin_auth`。
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"eventId": "daily-login:user:xxx:123",
|
||||
"eventKey": "daily_login",
|
||||
"eventTitle": "每日登录",
|
||||
"scopeKind": "user",
|
||||
"scopeId": "xxx",
|
||||
"dayKey": 20580,
|
||||
"userId": "xxx",
|
||||
"ownerUserId": null,
|
||||
"profileId": null,
|
||||
"moduleKey": "profile",
|
||||
"metadataJson": "{}",
|
||||
"occurredAt": "2026-05-07T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
后端实现要点:
|
||||
|
||||
1. DTO 放在 `shared-contracts/src/admin.rs`,避免 Rust 与前端口径分叉。
|
||||
2. Handler 放在 `api-server/src/admin.rs`,使用当前已有 SpacetimeDB HTTP SQL helper 思路。
|
||||
3. SQL 只读 `tracking_event`,固定白名单列,按 `occurred_at DESC` 排序,默认 200 条,最大 1000 条。
|
||||
4. 查询条件只通过字符串转义函数拼接,禁止直接拼接未转义用户输入。
|
||||
5. `eventTitle` 由后端根据已知事件 key 映射,未知事件返回 `eventKey`。
|
||||
|
||||
## 前端页面
|
||||
|
||||
新增路由:`#tracking`,导航标题为“埋点数据”。
|
||||
|
||||
页面能力:
|
||||
|
||||
1. 顶部筛选区:Event Key、用户 ID、Scope Kind、Scope ID、刷新、导出 Excel。
|
||||
2. 列表区:移动端可横向滚动,桌面端表格展示。
|
||||
3. 详情区:每行有“详情”按钮,弹出独立面板展示完整字段与格式化后的 metadata JSON。
|
||||
4. 导出:导出当前页面已加载结果,文件名形如 `tracking-events-2026-05-07.xls`。
|
||||
|
||||
导出实现:
|
||||
|
||||
- 使用 HTML table + Excel MIME:`application/vnd.ms-excel;charset=utf-8`。
|
||||
- 文件扩展名使用 `.xls`,Excel/WPS 可直接打开。
|
||||
- 所有单元格做 HTML 转义。
|
||||
- metadata 保留原始 JSON 文本,便于运营继续筛选。
|
||||
|
||||
## 验收命令
|
||||
|
||||
```bash
|
||||
npm run check:encoding
|
||||
npm run admin-web:typecheck
|
||||
cargo test -p shared-contracts -p api-server admin_tracking -- --nocapture
|
||||
```
|
||||
|
||||
如后端接口改动较大,再补充:
|
||||
|
||||
```bash
|
||||
npm run api-server
|
||||
curl http://127.0.0.1:<port>/healthz
|
||||
```
|
||||
|
||||
## 实施任务
|
||||
|
||||
### Task 1: 补充 shared-contracts 后台埋点 DTO
|
||||
|
||||
**Files:**
|
||||
- Modify: `server-rs/crates/shared-contracts/src/admin.rs`
|
||||
|
||||
**Steps:**
|
||||
1. 新增 `AdminTrackingEventListQuery`。
|
||||
2. 新增 `AdminTrackingEventEntryPayload`。
|
||||
3. 新增 `AdminTrackingEventListResponse`。
|
||||
4. 为 DTO 添加中文注释。
|
||||
|
||||
### Task 2: 增加后端后台埋点查询接口
|
||||
|
||||
**Files:**
|
||||
- Modify: `server-rs/crates/api-server/src/admin.rs`
|
||||
- Modify: `server-rs/crates/api-server/src/app.rs`
|
||||
|
||||
**Steps:**
|
||||
1. 在 `admin.rs` 新增 query 解析与 SQL 构造。
|
||||
2. 复用 SpacetimeDB HTTP SQL 调用风格读取 rows。
|
||||
3. 新增 `admin_list_tracking_events` handler。
|
||||
4. 在 `app.rs` 挂载 `/admin/api/tracking/events`。
|
||||
5. 添加单元测试覆盖 SQL 字符串转义、limit clamp、SQL 响应解析。
|
||||
|
||||
### Task 3: 增加前端 API 类型与客户端方法
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin-web/src/api/adminApiTypes.ts`
|
||||
- Modify: `apps/admin-web/src/api/adminApiClient.ts`
|
||||
|
||||
**Steps:**
|
||||
1. 新增埋点 entry/list/query 类型。
|
||||
2. 新增 `listAdminTrackingEvents(token, query)`。
|
||||
3. 使用 `URLSearchParams` 拼接非空查询字段。
|
||||
|
||||
### Task 4: 新增后台埋点数据页面
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/admin-web/src/pages/AdminTrackingEventsPage.tsx`
|
||||
- Modify: `apps/admin-web/src/styles/admin.css`
|
||||
|
||||
**Steps:**
|
||||
1. 实现筛选、刷新、错误状态。
|
||||
2. 实现明细表格。
|
||||
3. 实现独立详情面板。
|
||||
4. 实现 Excel `.xls` 导出。
|
||||
5. 保持 UI 简洁,不添加说明类大段文案。
|
||||
|
||||
### Task 5: 接入后台路由与导航
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/admin-web/src/app/adminRoutes.ts`
|
||||
- Modify: `apps/admin-web/src/app/AdminShell.tsx`
|
||||
- Modify: `apps/admin-web/src/app/AdminApp.tsx`
|
||||
|
||||
**Steps:**
|
||||
1. 增加 `tracking` 路由。
|
||||
2. 导航增加图标。
|
||||
3. `AdminApp` 渲染新页面。
|
||||
|
||||
### Task 6: 验证并提交
|
||||
|
||||
**Steps:**
|
||||
1. 运行 `npm run check:encoding`。
|
||||
2. 运行 `npm run admin-web:typecheck`。
|
||||
3. 运行后端相关 cargo test。
|
||||
4. 修复问题后提交并推送当前分支。
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Request, State},
|
||||
extract::{Extension, Query, Request, State},
|
||||
http::{
|
||||
HeaderMap, HeaderName, HeaderValue, Method, StatusCode,
|
||||
header::{AUTHORIZATION, CONTENT_TYPE},
|
||||
@@ -20,6 +20,7 @@ use shared_contracts::admin::{
|
||||
AdminDatabaseOverviewPayload, AdminDatabaseTableStatPayload, AdminDebugHeaderInput,
|
||||
AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse,
|
||||
AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload,
|
||||
AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse,
|
||||
};
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
|
||||
@@ -42,6 +43,8 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[
|
||||
// SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。
|
||||
// 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。
|
||||
const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9";
|
||||
const ADMIN_TRACKING_EVENT_DEFAULT_LIMIT: u32 = 200;
|
||||
const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthenticatedAdmin {
|
||||
@@ -153,6 +156,19 @@ pub async fn admin_debug_http(
|
||||
Ok(json_success_body(Some(&request_context), response))
|
||||
}
|
||||
|
||||
pub async fn admin_list_tracking_events(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Query(query): Query<AdminTrackingEventListQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let entries = fetch_admin_tracking_events(&state, query).await?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminTrackingEventListResponse { entries },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn require_admin_auth(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
@@ -488,6 +504,216 @@ fn parse_count_value(value: &Value) -> Result<u64, String> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_admin_tracking_events(
|
||||
state: &AppState,
|
||||
query: AdminTrackingEventListQuery,
|
||||
) -> Result<Vec<AdminTrackingEventEntryPayload>, AppError> {
|
||||
let client = Client::new();
|
||||
let server_root = state.config.spacetime_server_url.trim_end_matches('/');
|
||||
let database = state.config.spacetime_database.trim();
|
||||
let token = state
|
||||
.config
|
||||
.spacetime_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let sql = build_admin_tracking_events_sql(&query)
|
||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))?;
|
||||
|
||||
let payload = fetch_spacetime_sql_json(&client, server_root, database, token, &sql)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("埋点数据读取失败:{error}"))
|
||||
})?;
|
||||
parse_admin_tracking_events_sql_response(payload).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(format!("埋点数据解析失败:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_admin_tracking_events_sql(query: &AdminTrackingEventListQuery) -> Result<String, String> {
|
||||
let mut conditions = Vec::new();
|
||||
if let Some(value) = normalized_non_empty(query.event_key.as_deref()) {
|
||||
conditions.push(format!("event_key = {}", quote_sql_string(value)));
|
||||
}
|
||||
if let Some(value) = normalized_non_empty(query.user_id.as_deref()) {
|
||||
conditions.push(format!("user_id = {}", quote_sql_string(value)));
|
||||
}
|
||||
if let Some(value) = normalized_non_empty(query.scope_kind.as_deref()) {
|
||||
let scope_kind = normalize_admin_tracking_scope_kind(value)?;
|
||||
conditions.push(format!("scope_kind = {}", quote_sql_string(scope_kind)));
|
||||
}
|
||||
if let Some(value) = normalized_non_empty(query.scope_id.as_deref()) {
|
||||
conditions.push(format!("scope_id = {}", quote_sql_string(value)));
|
||||
}
|
||||
|
||||
let where_clause = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", conditions.join(" AND "))
|
||||
};
|
||||
let limit = clamp_admin_tracking_event_limit(query.limit);
|
||||
Ok(format!(
|
||||
"SELECT event_id, event_key, scope_kind, scope_id, day_key, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at FROM tracking_event{where_clause} ORDER BY occurred_at DESC LIMIT {limit}"
|
||||
))
|
||||
}
|
||||
|
||||
fn normalized_non_empty(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn quote_sql_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn normalize_admin_tracking_scope_kind(value: &str) -> Result<&'static str, String> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"site" => Ok("site"),
|
||||
"work" => Ok("work"),
|
||||
"module" => Ok("module"),
|
||||
"user" => Ok("user"),
|
||||
_ => Err("scopeKind 必须是 site/work/module/user".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_admin_tracking_event_limit(limit: Option<u32>) -> u32 {
|
||||
limit
|
||||
.unwrap_or(ADMIN_TRACKING_EVENT_DEFAULT_LIMIT)
|
||||
.clamp(1, ADMIN_TRACKING_EVENT_MAX_LIMIT)
|
||||
}
|
||||
|
||||
async fn fetch_spacetime_sql_json(
|
||||
client: &Client,
|
||||
server_root: &str,
|
||||
database: &str,
|
||||
token: Option<&str>,
|
||||
sql: &str,
|
||||
) -> Result<Value, String> {
|
||||
let mut request = client
|
||||
.post(format!("{server_root}/v1/database/{database}/sql"))
|
||||
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(sql.to_string());
|
||||
if let Some(token) = token {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("SQL 请求失败:{error}"))?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body)));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|error| format!("SQL 响应解析失败:{error}"))
|
||||
}
|
||||
|
||||
fn parse_admin_tracking_events_sql_response(
|
||||
payload: Value,
|
||||
) -> Result<Vec<AdminTrackingEventEntryPayload>, String> {
|
||||
let rows = extract_first_sql_rows(payload)?;
|
||||
rows.iter()
|
||||
.map(parse_admin_tracking_event_row)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
}
|
||||
|
||||
fn extract_first_sql_rows(payload: Value) -> Result<Vec<Value>, String> {
|
||||
let statement = match payload {
|
||||
Value::Array(statements) => statements
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| "SQL 结果为空".to_string())?,
|
||||
Value::Object(statement) => Value::Object(statement),
|
||||
_ => return Err("SQL 响应格式非法".to_string()),
|
||||
};
|
||||
let Value::Object(mut statement) = statement else {
|
||||
return Err("SQL statement 结果格式非法".to_string());
|
||||
};
|
||||
let rows = statement
|
||||
.remove("rows")
|
||||
.ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?;
|
||||
match rows {
|
||||
Value::Array(rows) => Ok(rows),
|
||||
_ => Err("SQL rows 字段格式非法".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_admin_tracking_event_row(row: &Value) -> Result<AdminTrackingEventEntryPayload, String> {
|
||||
let columns = row.as_array().ok_or_else(|| "埋点行格式非法".to_string())?;
|
||||
let event_key = required_string_column(columns, 1, "event_key")?;
|
||||
Ok(AdminTrackingEventEntryPayload {
|
||||
event_id: required_string_column(columns, 0, "event_id")?,
|
||||
event_title: admin_tracking_event_title(&event_key).to_string(),
|
||||
event_key,
|
||||
scope_kind: required_string_column(columns, 2, "scope_kind")?,
|
||||
scope_id: required_string_column(columns, 3, "scope_id")?,
|
||||
day_key: required_i64_column(columns, 4, "day_key")?,
|
||||
user_id: optional_string_column(columns, 5),
|
||||
owner_user_id: optional_string_column(columns, 6),
|
||||
profile_id: optional_string_column(columns, 7),
|
||||
module_key: optional_string_column(columns, 8),
|
||||
metadata_json: required_string_column(columns, 9, "metadata_json")?,
|
||||
occurred_at: required_string_column(columns, 10, "occurred_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn required_string_column(
|
||||
columns: &[Value],
|
||||
index: usize,
|
||||
field_name: &str,
|
||||
) -> Result<String, String> {
|
||||
value_to_string(
|
||||
columns
|
||||
.get(index)
|
||||
.ok_or_else(|| format!("埋点行缺少 {field_name}"))?,
|
||||
)
|
||||
.ok_or_else(|| format!("埋点行 {field_name} 不是字符串"))
|
||||
}
|
||||
|
||||
fn optional_string_column(columns: &[Value], index: usize) -> Option<String> {
|
||||
columns.get(index).and_then(value_to_string)
|
||||
}
|
||||
|
||||
fn required_i64_column(columns: &[Value], index: usize, field_name: &str) -> Result<i64, String> {
|
||||
let value = columns
|
||||
.get(index)
|
||||
.ok_or_else(|| format!("埋点行缺少 {field_name}"))?;
|
||||
match value {
|
||||
Value::Number(number) => number
|
||||
.as_i64()
|
||||
.ok_or_else(|| format!("埋点行 {field_name} 不是整数")),
|
||||
Value::String(text) => text
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|error| format!("埋点行 {field_name} 解析失败:{error}")),
|
||||
_ => Err(format!("埋点行 {field_name} 类型非法")),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_string(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Object(object) => object.get("some").and_then(value_to_string),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
Value::Bool(value) => Some(value.to_string()),
|
||||
_ => Some(value.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_tracking_event_title(event_key: &str) -> &str {
|
||||
match event_key {
|
||||
"daily_login" => "每日登录",
|
||||
_ => event_key,
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_admin_debug_http(
|
||||
state: &AppState,
|
||||
payload: AdminDebugHttpRequest,
|
||||
@@ -648,12 +874,14 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_body_preview, build_debug_base_url, build_spacetime_schema_url,
|
||||
is_safe_spacetime_table_name, normalize_debug_path, normalize_table_count_error,
|
||||
parse_spacetime_sql_count_response, trim_preview,
|
||||
build_admin_tracking_events_sql, build_body_preview, build_debug_base_url,
|
||||
build_spacetime_schema_url, clamp_admin_tracking_event_limit, is_safe_spacetime_table_name,
|
||||
normalize_debug_path, normalize_table_count_error,
|
||||
parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, trim_preview,
|
||||
};
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
use serde_json::json;
|
||||
use shared_contracts::admin::AdminTrackingEventListQuery;
|
||||
|
||||
#[test]
|
||||
fn normalize_debug_path_rejects_absolute_url() {
|
||||
@@ -816,6 +1044,61 @@ mod tests {
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_admin_tracking_events_sql_quotes_filters_and_clamps_limit() {
|
||||
let sql = build_admin_tracking_events_sql(&AdminTrackingEventListQuery {
|
||||
event_key: Some("daily'login".to_string()),
|
||||
user_id: Some("user-1".to_string()),
|
||||
scope_kind: Some("USER".to_string()),
|
||||
scope_id: Some("scope-1".to_string()),
|
||||
limit: Some(2000),
|
||||
})
|
||||
.expect("tracking sql should build");
|
||||
|
||||
assert!(sql.contains("event_key = 'daily''login'"));
|
||||
assert!(sql.contains("user_id = 'user-1'"));
|
||||
assert!(sql.contains("scope_kind = 'user'"));
|
||||
assert!(sql.contains("scope_id = 'scope-1'"));
|
||||
assert!(sql.ends_with("LIMIT 1000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_admin_tracking_event_limit_uses_default_and_bounds() {
|
||||
assert_eq!(clamp_admin_tracking_event_limit(None), 200);
|
||||
assert_eq!(clamp_admin_tracking_event_limit(Some(0)), 1);
|
||||
assert_eq!(clamp_admin_tracking_event_limit(Some(1001)), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_admin_tracking_events_sql_response_accepts_statement_array_rows() {
|
||||
let payload = json!([
|
||||
{
|
||||
"rows": [[
|
||||
"event-1",
|
||||
"daily_login",
|
||||
"user",
|
||||
"user-1",
|
||||
20580,
|
||||
{"some": "user-1"},
|
||||
null,
|
||||
{"some": "profile-1"},
|
||||
"profile",
|
||||
"{\"source\":\"task\"}",
|
||||
"2026-05-07T00:00:00Z"
|
||||
]]
|
||||
}
|
||||
]);
|
||||
|
||||
let entries =
|
||||
parse_admin_tracking_events_sql_response(payload).expect("tracking rows should parse");
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].event_id, "event-1");
|
||||
assert_eq!(entries[0].event_title, "每日登录");
|
||||
assert_eq!(entries[0].user_id.as_deref(), Some("user-1"));
|
||||
assert_eq!(entries[0].profile_id.as_deref(), Some("profile-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_body_preview_handles_utf8() {
|
||||
let preview = build_body_preview("后台测试".as_bytes());
|
||||
|
||||
@@ -13,7 +13,10 @@ use tower_http::{
|
||||
use tracing::{Level, Span, error, info, info_span, warn};
|
||||
|
||||
use crate::{
|
||||
admin::{admin_debug_http, admin_login, admin_me, admin_overview, require_admin_auth},
|
||||
admin::{
|
||||
admin_debug_http, admin_list_tracking_events, admin_login, admin_me, admin_overview,
|
||||
require_admin_auth,
|
||||
},
|
||||
ai_tasks::{
|
||||
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
|
||||
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
|
||||
@@ -168,6 +171,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/tracking/events",
|
||||
get(admin_list_tracking_events).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/redeem-codes",
|
||||
get(admin_list_profile_redeem_codes)
|
||||
|
||||
@@ -105,3 +105,39 @@ pub struct AdminDebugHttpResponse {
|
||||
pub body_text: String,
|
||||
pub body_json: Option<Value>,
|
||||
}
|
||||
|
||||
// 后台埋点明细查询参数只保留运营筛选需要的只读字段。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminTrackingEventListQuery {
|
||||
pub event_key: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub scope_kind: Option<String>,
|
||||
pub scope_id: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
// 单条埋点原始事件明细,字段与 tracking_event 表一一对应并补充事件名称。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminTrackingEventEntryPayload {
|
||||
pub event_id: String,
|
||||
pub event_key: String,
|
||||
pub event_title: String,
|
||||
pub scope_kind: String,
|
||||
pub scope_id: String,
|
||||
pub day_key: i64,
|
||||
pub user_id: Option<String>,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub module_key: Option<String>,
|
||||
pub metadata_json: String,
|
||||
pub occurred_at: String,
|
||||
}
|
||||
|
||||
// 后台埋点明细列表响应,前端导出 Excel 时直接使用 entries。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminTrackingEventListResponse {
|
||||
pub entries: Vec<AdminTrackingEventEntryPayload>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user