补齐后台埋点导出和任务事件选择

后台埋点查询支持日期筛选和导出全部模式

新增后台真实 event key 列表接口

任务配置页改用真实 user 埋点 key 候选

更新后台埋点查询文档和定向测试
This commit is contained in:
2026-06-26 16:49:33 +08:00
parent 607042a250
commit a8c3f8cf0f
10 changed files with 613 additions and 88 deletions
+13
View File
@@ -15,6 +15,7 @@ import type {
AdminMeResponse,
AdminOverviewResponse,
AdminTrackingEventListQuery,
AdminTrackingEventKeyListResponse,
AdminTrackingEventListResponse,
AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse,
@@ -192,6 +193,13 @@ export function listAdminTrackingEvents(
);
}
export function listAdminTrackingEventKeys(token: string) {
return request<AdminTrackingEventKeyListResponse>(
'/admin/api/tracking/event-keys',
{ token },
);
}
export function getAdminCreationEntryConfig(token: string) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config',
@@ -430,9 +438,14 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
appendQueryParam(params, 'userId', query.userId);
appendQueryParam(params, 'scopeKind', query.scopeKind);
appendQueryParam(params, 'scopeId', query.scopeId);
appendQueryParam(params, 'startDate', query.startDate);
appendQueryParam(params, 'endDate', query.endDate);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
if (query.exportAll) {
params.set('exportAll', 'true');
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
+13
View File
@@ -206,7 +206,10 @@ export interface AdminTrackingEventListQuery {
userId?: string;
scopeKind?: TrackingScopeKind | '';
scopeId?: string;
startDate?: string;
endDate?: string;
limit?: number;
exportAll?: boolean;
}
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
@@ -503,3 +506,13 @@ export interface AdminTrackingEventEntryPayload {
export interface AdminTrackingEventListResponse {
entries: AdminTrackingEventEntryPayload[];
}
export interface AdminTrackingEventKeyPayload {
eventKey: string;
eventTitle: string;
scopeKinds: string[];
}
export interface AdminTrackingEventKeyListResponse {
eventKeys: AdminTrackingEventKeyPayload[];
}
@@ -1,16 +1,20 @@
import {describe, expect, test} from 'vitest';
import { describe, expect, test } from 'vitest';
import {
adminProfileTaskTrackingEventDefinitions,
adminTrackingEventDefinitions,
buildAdminTrackingEventKeyOptions,
filterAdminProfileTaskTrackingEventDefinitions,
filterAdminTrackingEventKeyOptions,
filterAdminTrackingEventDefinitions,
findAdminTrackingEventDefinition,
} from './trackingEventDefinitions';
describe('admin tracking event definitions', () => {
test('后台埋点筛选候选包含后端通用埋点清单', () => {
const keys = adminTrackingEventDefinitions.map((definition) => definition.key);
const keys = adminTrackingEventDefinitions.map(
(definition) => definition.key,
);
expect(keys.length).toBeGreaterThan(40);
expect(keys).toContain('daily_login');
@@ -22,21 +26,48 @@ describe('admin tracking event definitions', () => {
});
test('任务配置候选只开放适合个人任务的事件', () => {
expect(adminProfileTaskTrackingEventDefinitions.map(({key}) => key)).toEqual([
'daily_login',
]);
expect(filterAdminProfileTaskTrackingEventDefinitions('').map(({key}) => key)).toEqual([
'daily_login',
expect(
adminProfileTaskTrackingEventDefinitions.map(({ key }) => key),
).toEqual(['daily_login']);
expect(
filterAdminProfileTaskTrackingEventDefinitions('').map(({ key }) => key),
).toEqual(['daily_login']);
});
test('后台埋点 key 候选从真实库响应合并静态标题', () => {
const options = buildAdminTrackingEventKeyOptions([
{
eventKey: 'work_play_start',
eventTitle: 'work_play_start',
scopeKinds: ['work', 'user'],
},
{
eventKey: 'unknown_event',
eventTitle: '未知事件',
scopeKinds: ['user'],
},
]);
expect(options.find(({ key }) => key === 'work_play_start')?.title).toBe(
'作品开始游玩',
);
expect(options.find(({ key }) => key === 'unknown_event')?.title).toBe(
'未知事件',
);
expect(
filterAdminTrackingEventKeyOptions(options, '作品').map(({ key }) => key),
).toEqual(['work_play_start']);
});
test('后台埋点筛选支持按中文名称和 key 搜索', () => {
expect(filterAdminTrackingEventDefinitions('上传票据').map(({key}) => key)).toEqual([
'asset_upload_ticket_create',
]);
expect(filterAdminTrackingEventDefinitions('work_play').map(({key}) => key)).toEqual([
'work_play_start',
]);
expect(findAdminTrackingEventDefinition(' daily_login ')?.title).toBe('每日登录');
expect(
filterAdminTrackingEventDefinitions('上传票据').map(({ key }) => key),
).toEqual(['asset_upload_ticket_create']);
expect(
filterAdminTrackingEventDefinitions('work_play').map(({ key }) => key),
).toEqual(['work_play_start']);
expect(findAdminTrackingEventDefinition(' daily_login ')?.title).toBe(
'每日登录',
);
});
});
@@ -1,4 +1,7 @@
import type {TrackingScopeKind} from '../api/adminApiTypes';
import type {
AdminTrackingEventKeyPayload,
TrackingScopeKind,
} from '../api/adminApiTypes';
export interface AdminTrackingEventDefinition {
key: string;
@@ -8,6 +11,14 @@ export interface AdminTrackingEventDefinition {
taskConfigEligible?: boolean;
}
export interface AdminTrackingEventKeyOption {
key: string;
title: string;
scopeKind: TrackingScopeKind | string;
scopeKinds: string[];
remark: string;
}
export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
{
key: 'auth_login_options_view',
@@ -25,7 +36,8 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
key: 'daily_login',
title: '每日登录',
scopeKind: 'user',
remark: '认证成功或 refresh 续期后由后端幂等记录,用于每日登录任务进度校验。',
remark:
'认证成功或 refresh 续期后由后端幂等记录,用于每日登录任务进度校验。',
taskConfigEligible: true,
},
{
@@ -368,7 +380,8 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
key: 'match3d_route_success',
title: '抓大鹅路由成功',
scopeKind: 'user',
remark: '抓大鹅创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
remark:
'抓大鹅创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
},
{
key: 'square_hole_route_success',
@@ -392,12 +405,15 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
key: 'work_play_start',
title: '作品开始游玩',
scopeKind: 'work',
remark: '拼图、抓大鹅、方洞、自定义世界、大鱼吃小鱼、Visual Novel 正式开始游玩时记录。',
remark:
'拼图、抓大鹅、方洞、自定义世界、大鱼吃小鱼、Visual Novel 正式开始游玩时记录。',
},
];
export const adminProfileTaskTrackingEventDefinitions =
adminTrackingEventDefinitions.filter((definition) => definition.taskConfigEligible);
adminTrackingEventDefinitions.filter(
(definition) => definition.taskConfigEligible,
);
export function findAdminTrackingEventDefinition(eventKey: string) {
const normalizedEventKey = eventKey.trim();
@@ -412,6 +428,41 @@ export function filterAdminTrackingEventDefinitions(query: string) {
return filterTrackingEventDefinitions(adminTrackingEventDefinitions, query);
}
export function buildAdminTrackingEventKeyOptions(
eventKeys: AdminTrackingEventKeyPayload[],
): AdminTrackingEventKeyOption[] {
const options = new Map<string, AdminTrackingEventKeyOption>();
for (const payload of eventKeys) {
const key = payload.eventKey.trim();
if (!key || options.has(key)) {
continue;
}
const definition = findAdminTrackingEventDefinition(key);
const scopeKinds = payload.scopeKinds.length
? payload.scopeKinds
: definition
? [definition.scopeKind]
: [];
options.set(key, {
key,
title: definition?.title ?? (payload.eventTitle || key),
scopeKind: definition?.scopeKind ?? scopeKinds[0] ?? '',
scopeKinds,
remark: definition?.remark ?? '真实埋点库中已出现的 event key。',
});
}
return [...options.values()].sort((left, right) =>
left.key.localeCompare(right.key),
);
}
export function filterAdminTrackingEventKeyOptions(
options: AdminTrackingEventKeyOption[],
query: string,
) {
return filterTrackingEventDefinitions(options, query);
}
export function filterAdminProfileTaskTrackingEventDefinitions(query: string) {
return filterTrackingEventDefinitions(
adminProfileTaskTrackingEventDefinitions,
@@ -420,7 +471,9 @@ export function filterAdminProfileTaskTrackingEventDefinitions(query: string) {
}
function filterTrackingEventDefinitions(
definitions: AdminTrackingEventDefinition[],
definitions: Array<
AdminTrackingEventDefinition | AdminTrackingEventKeyOption
>,
query: string,
) {
const normalizedQuery = query.trim().toLowerCase();
@@ -1,22 +1,25 @@
import {ChevronDown, PowerOff, RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import { ChevronDown, PowerOff, RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import {
disableProfileTaskConfig,
listAdminTrackingEventKeys,
listProfileTaskConfigs,
upsertProfileTaskConfig,
} from '../api/adminApiClient';
import type {
AdminTrackingEventKeyPayload,
ProfileTaskConfigAdminResponse,
ProfileTaskCycle,
TrackingScopeKind,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import {
filterAdminProfileTaskTrackingEventDefinitions,
buildAdminTrackingEventKeyOptions,
filterAdminTrackingEventKeyOptions,
findAdminTrackingEventDefinition,
} from '../config/trackingEventDefinitions';
import {handlePageError} from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminTaskConfigPageProps {
token: string;
@@ -25,8 +28,8 @@ interface AdminTaskConfigPageProps {
onResultChange: (result: ProfileTaskConfigAdminResponse) => void;
}
const taskCycles: Array<{value: ProfileTaskCycle; label: string}> = [
{value: 'daily', label: '每日'},
const taskCycles: Array<{ value: ProfileTaskCycle; label: string }> = [
{ value: 'daily', label: '每日' },
];
const profileTaskScopeKind = 'user' satisfies TrackingScopeKind;
@@ -43,6 +46,9 @@ export function AdminTaskConfigPage({
const [description, setDescription] = useState('');
const [eventKey, setEventKey] = useState('daily_login');
const [eventKeySearch, setEventKeySearch] = useState('每日登录');
const [eventKeys, setEventKeys] = useState<AdminTrackingEventKeyPayload[]>(
[],
);
const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false);
const [cycle, setCycle] = useState<ProfileTaskCycle>('daily');
const [threshold, setThreshold] = useState('1');
@@ -56,22 +62,53 @@ export function AdminTaskConfigPage({
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshTaskConfigs();
void refreshTrackingEventKeys();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const selectedEventDefinition = useMemo(
() => findAdminTrackingEventDefinition(eventKey),
[eventKey],
const eventKeyOptions = useMemo(
() =>
buildAdminTrackingEventKeyOptions(eventKeys).filter((option) =>
option.scopeKinds.includes(profileTaskScopeKind),
),
[eventKeys],
);
const selectedEventOption = useMemo(
() =>
eventKeyOptions.find((option) => option.key === eventKey) ??
findAdminTrackingEventDefinition(eventKey),
[eventKey, eventKeyOptions],
);
const filteredEventDefinitions = useMemo(
() => filterAdminProfileTaskTrackingEventDefinitions(eventKeySearch),
[eventKeySearch],
() => filterAdminTrackingEventKeyOptions(eventKeyOptions, eventKeySearch),
[eventKeyOptions, eventKeySearch],
);
useEffect(() => {
if (eventKeySearch !== eventKey) {
return;
}
const nextOption =
eventKeyOptions.find((option) => option.key === eventKey) ??
findAdminTrackingEventDefinition(eventKey);
if (nextOption) {
setEventKeySearch(nextOption.title);
}
}, [eventKey, eventKeyOptions, eventKeySearch]);
async function refreshTrackingEventKeys() {
try {
const response = await listAdminTrackingEventKeys(token);
setEventKeys(response.eventKeys);
} catch {
setEventKeys([]);
}
}
async function refreshTaskConfigs() {
setIsLoading(true);
setListErrorMessage('');
@@ -182,16 +219,20 @@ export function AdminTaskConfigPage({
setSortOrder(String(entry.sortOrder));
setEnabled(entry.enabled);
setDisableTaskId(entry.taskId);
const nextDefinition = findAdminTrackingEventDefinition(entry.eventKey);
setEventKeySearch(nextDefinition?.title ?? entry.eventKey);
const nextOption =
eventKeyOptions.find((option) => option.key === entry.eventKey) ??
findAdminTrackingEventDefinition(entry.eventKey);
setEventKeySearch(nextOption?.title ?? entry.eventKey);
setIsEventKeyPickerOpen(false);
}
function selectEventKey(nextEventKey: string) {
const nextDefinition = findAdminTrackingEventDefinition(nextEventKey);
const nextOption =
eventKeyOptions.find((option) => option.key === nextEventKey) ??
findAdminTrackingEventDefinition(nextEventKey);
setEventKey(nextEventKey);
if (nextDefinition) {
setEventKeySearch(nextDefinition.title);
if (nextOption) {
setEventKeySearch(nextOption.title);
} else {
setEventKeySearch(nextEventKey);
}
@@ -322,9 +363,9 @@ export function AdminTaskConfigPage({
</div>
) : null}
</div>
{selectedEventDefinition ? (
{selectedEventOption ? (
<small className="admin-field-note">
{selectedEventDefinition.remark}
{selectedEventOption.remark}
</small>
) : (
<small className="admin-field-note">
@@ -1,46 +1,55 @@
import {Download, Eye, RefreshCcw, Search, X} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import { Download, Eye, RefreshCcw, Search, X } from 'lucide-react';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import {listAdminTrackingEvents} from '../api/adminApiClient';
import {
listAdminTrackingEventKeys,
listAdminTrackingEvents,
} from '../api/adminApiClient';
import type {
AdminTrackingEventEntryPayload,
AdminTrackingEventKeyPayload,
TrackingScopeKind,
} from '../api/adminApiTypes';
import {
buildAdminTrackingEventKeyOptions,
filterAdminTrackingEventKeyOptions,
filterAdminTrackingEventDefinitions,
findAdminTrackingEventDefinition,
} from '../config/trackingEventDefinitions';
import {handlePageError} from './pageUtils';
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 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: '发生时间'},
{ 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({
@@ -52,21 +61,47 @@ export function AdminTrackingEventsPage({
const [userId, setUserId] = useState('');
const [scopeKind, setScopeKind] = useState<TrackingScopeKind | ''>('');
const [scopeId, setScopeId] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [limit, setLimit] = useState('200');
const [eventKeys, setEventKeys] = useState<AdminTrackingEventKeyPayload[]>(
[],
);
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isExportingAll, setIsExportingAll] = useState(false);
const [detailEntry, setDetailEntry] =
useState<AdminTrackingEventEntryPayload | null>(null);
useEffect(() => {
void refreshTrackingEventKeys();
void refreshTrackingEvents();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const filteredEventDefinitions = useMemo(
() => filterAdminTrackingEventDefinitions(eventKey),
[eventKey],
const eventKeyOptions = useMemo(
() => buildAdminTrackingEventKeyOptions(eventKeys),
[eventKeys],
);
const filteredEventDefinitions = useMemo(() => {
const dynamicOptions = filterAdminTrackingEventKeyOptions(
eventKeyOptions,
eventKey,
);
if (dynamicOptions.length) {
return dynamicOptions;
}
return filterAdminTrackingEventDefinitions(eventKey);
}, [eventKey, eventKeyOptions]);
async function refreshTrackingEventKeys() {
try {
const response = await listAdminTrackingEventKeys(token);
setEventKeys(response.eventKeys);
} catch {
setEventKeys([]);
}
}
async function refreshTrackingEvents() {
setIsLoading(true);
@@ -77,6 +112,8 @@ export function AdminTrackingEventsPage({
userId,
scopeKind,
scopeId,
startDate,
endDate,
limit: parseLimit(limit),
});
setEntries(response.entries);
@@ -100,6 +137,32 @@ export function AdminTrackingEventsPage({
exportTrackingEventsAsExcel(entries);
}
async function handleExportAll() {
setIsExportingAll(true);
setErrorMessage('');
try {
const response = await listAdminTrackingEvents(token, {
eventKey,
userId,
scopeKind,
scopeId,
startDate,
endDate,
limit: 100_000,
exportAll: true,
});
if (!response.entries.length) {
setErrorMessage('当前没有可导出的埋点数据');
return;
}
exportTrackingEventsAsExcel(response.entries);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsExportingAll(false);
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
@@ -110,7 +173,7 @@ export function AdminTrackingEventsPage({
<div className="admin-action-row">
<button
className="admin-secondary-button"
disabled={isLoading}
disabled={isLoading || isExportingAll}
type="button"
onClick={refreshTrackingEvents}
>
@@ -126,6 +189,15 @@ export function AdminTrackingEventsPage({
<Download size={17} aria-hidden="true" />
<span> Excel</span>
</button>
<button
className="admin-secondary-button"
disabled={isExportingAll}
type="button"
onClick={handleExportAll}
>
<Download size={17} aria-hidden="true" />
<span>{isExportingAll ? '导出中' : '导出全部'}</span>
</button>
</div>
</div>
@@ -186,7 +258,27 @@ export function AdminTrackingEventsPage({
onChange={(event) => setLimit(event.target.value)}
/>
</label>
<button className="admin-secondary-button" disabled={isLoading} type="submit">
<label className="admin-field">
<span></span>
<input
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
type="date"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
</label>
<button
className="admin-secondary-button"
disabled={isLoading}
type="submit"
>
<Search size={17} aria-hidden="true" />
<span>{isLoading ? '查询中' : '查询'}</span>
</button>
@@ -353,9 +445,13 @@ function formatMetadataJson(value: string) {
}
}
function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[]) {
function exportTrackingEventsAsExcel(
entries: AdminTrackingEventEntryPayload[],
) {
const tableRows = [
exportColumns.map((column) => `<th>${escapeHtml(column.label)}</th>`).join(''),
exportColumns
.map((column) => `<th>${escapeHtml(column.label)}</th>`)
.join(''),
...entries.map((entry) =>
exportColumns
.map(
@@ -368,7 +464,9 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[])
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 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;
@@ -379,7 +477,10 @@ function exportTrackingEventsAsExcel(entries: AdminTrackingEventEntryPayload[])
URL.revokeObjectURL(url);
}
function formatExportCell(value: unknown, key?: keyof AdminTrackingEventEntryPayload) {
function formatExportCell(
value: unknown,
key?: keyof AdminTrackingEventEntryPayload,
) {
if (value === null || typeof value === 'undefined') {
return '';
}