feat(analytics): persona logging upgrade and Sentry system config

Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 22:30:02 +08:00
parent ab10431001
commit 89fd333702
91 changed files with 1805 additions and 136 deletions
+2
View File
@@ -38,6 +38,7 @@ import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage';
import UserLogsPage from './pages/UserLogsPage';
import DomainEventsPage from './pages/DomainEventsPage';
import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
import StoreLogsPage from './pages/StoreLogsPage';
@@ -120,6 +121,7 @@ export default function App() {
<Route path="/logs/partners" element={<PartnerLogsPage />} />
<Route path="/logs/hq" element={<HqLogsPage />} />
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
<Route path="/logs/domain-events" element={<DomainEventsPage />} />
<Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
@@ -122,6 +122,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/logs/partners', label: '合伙人日志' },
{ key: '/logs/hq', label: 'HQ 操作日志' },
{ key: '/logs/third-party', label: '第三方日志' },
{ key: '/logs/domain-events', label: '领域事件' },
],
},
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
@@ -182,6 +183,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/logs/partners': 'logs',
'/logs/hq': 'logs',
'/logs/third-party': 'logs',
'/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions',
'/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts',
+9 -42
View File
@@ -1,45 +1,12 @@
export type UserLogCategory =
| 'login'
| 'browse_product'
| 'order'
| 'pay'
| 'wechat_auth'
| 'browse_store'
| 'redeem'
| 'profile';
export {
USER_LOG_CATEGORY_OPTIONS,
resolveUserLogCategory,
eventNamesForUserLogCategory,
type UserLogCategory,
} from '@dukang/shared-types';
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
browse_product: ['home_view', 'product_click', 'product_detail_view'],
order: ['order_confirm_view', 'order_submit'],
pay: ['pay_success', 'pay_fail'],
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
browse_store: ['store_list_view', 'store_detail_view'],
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
profile: ['profile_update', 'bind_phone'],
};
import { USER_LOG_CATEGORY_OPTIONS } from '@dukang/shared-types';
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
{ value: '', label: '全部' },
{ value: 'login', label: '登录' },
{ value: 'browse_product', label: '浏览商品' },
{ value: 'order', label: '下单' },
{ value: 'pay', label: '支付' },
{ value: 'wechat_auth', label: '微信授权' },
{ value: 'browse_store', label: '浏览门店' },
{ value: 'redeem', label: '核销' },
{ value: 'profile', label: '信息修改' },
];
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
[UserLogCategory, readonly string[]]
>) {
if (events.includes(eventName)) return category;
}
return null;
}
export const USER_LOG_CATEGORY_LABELS = Object.fromEntries(
export const USER_LOG_CATEGORY_LABELS: Record<string, string> = Object.fromEntries(
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
) as Record<string, string>;
);
@@ -0,0 +1,127 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
type Row = {
id: string;
eventType: string;
refType: string;
refId: string;
status?: string | null;
param1?: string | null;
param2?: string | null;
param3?: string | null;
remark?: string | null;
createdAt: string;
};
const EVENT_OPTIONS = [
{ value: '', label: '全部领域事件' },
{ value: 'ORDER_STATUS', label: '订单状态' },
{ value: 'BENEFIT_LEDGER', label: '权益流水' },
{ value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'TICKET_COLLAB', label: '工单协作' },
{ value: 'PROMO_TOUCH', label: '推广触达' },
];
export default function DomainEventsPage() {
const [params, setParams] = useSearchParams();
const eventType = params.get('eventType') ?? '';
const refType = params.get('refType') ?? '';
const refId = params.get('refId') ?? '';
const [data, setData] = useState<{ items: Row[]; total: number; page: number; pageSize: number } | null>(null);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Row | null>(null);
const load = useCallback(() => {
setLoading(true);
const q = new URLSearchParams();
if (eventType) q.set('eventType', eventType);
if (refType) q.set('refType', refType);
if (refId) q.set('refId', refId);
q.set('page', params.get('page') ?? '1');
q.set('pageSize', '20');
request<{ items: Row[]; total: number; page: number; pageSize: number }>(
`/admin/logs/domain-events?${q.toString()}`,
)
.then(setData)
.finally(() => setLoading(false));
}, [eventType, refType, refId, params]);
useEffect(() => {
load();
}, [load]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
{
title: '操作',
width: 80,
render: (_, r) => (
<a
onClick={async () => {
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
}}
>
</a>
),
},
];
return (
<Card title="领域事件">
<Space wrap style={{ marginBottom: 16 }}>
<Select
style={{ width: 180 }}
value={eventType}
options={EVENT_OPTIONS}
onChange={(v) => {
const next = new URLSearchParams(params);
if (v) next.set('eventType', v);
else next.delete('eventType');
next.set('page', '1');
setParams(next);
}}
/>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: data?.page ?? 1,
pageSize: data?.pageSize ?? 20,
total: data?.total ?? 0,
onChange: (p) => {
const next = new URLSearchParams(params);
next.set('page', String(p));
setParams(next);
},
}}
/>
<Drawer open={!!detail} title="事件详情" width={520} onClose={() => setDetail(null)}>
{detail && (
<Descriptions column={1} size="small">
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</Card>
);
}
+39 -2
View File
@@ -17,7 +17,7 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
@@ -30,6 +30,14 @@ type UserOrderRow = {
createdAt: string;
};
type UserBehaviorLog = {
id: string;
eventName: string;
clientApp?: string | null;
createdAt: string;
extraJson?: Record<string, unknown> | null;
};
type UserDetail = AdminUserRow & {
wxUnionId?: string | null;
cityPref?: Record<string, unknown> | null;
@@ -72,6 +80,7 @@ export default function UsersPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<UserDetail | null>(null);
const [behaviorLogs, setBehaviorLogs] = useState<UserBehaviorLog[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState('');
@@ -123,8 +132,14 @@ export default function UsersPage() {
}, [location.state, location.pathname, navigate]);
async function openDetail(id: string) {
const res = await request<UserDetail>(`/admin/users/${id}`);
const [res, logs] = await Promise.all([
request<UserDetail>(`/admin/users/${id}`),
request<{ items: UserBehaviorLog[] }>(`/admin/logs/users?userId=${id}&pageSize=50`).catch(
() => ({ items: [] as UserBehaviorLog[] }),
),
]);
setDetail(res);
setBehaviorLogs(logs.items ?? []);
setDrawerOpen(true);
}
@@ -437,6 +452,28 @@ export default function UsersPage() {
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
线 {behaviorLogs.length}
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
scroll={{ y: 200 }}
dataSource={behaviorLogs}
locale={{ emptyText: '暂无行为日志' }}
columns={[
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v) => fmtTime(v) },
{
title: '分类',
width: 100,
render: (_, r) => resolveUserLogCategory(r.eventName) ?? '—',
},
{ title: '事件', dataIndex: 'eventName' },
{ title: '端', dataIndex: 'clientApp', width: 90 },
]}
/>
<Typography.Title level={5} style={{ marginTop: 16 }}>
{detail.orders?.length ?? 0}
</Typography.Title>
+1
View File
@@ -9,6 +9,7 @@
"lint": "echo ok"
},
"dependencies": {
"@dukang/client-logging": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
+16
View File
@@ -0,0 +1,16 @@
import { createPartnerTracker } from '@dukang/client-logging';
import { apiBase } from './api';
const tracker = createPartnerTracker({
apiBase,
clientApp: 'PARTNER_H5',
getToken: () => localStorage.getItem('partnerAccessToken'),
});
export function trackPartner(eventName: string, params?: Record<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPartnerPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackPartnerPageView } from './analytics';
export function usePartnerPageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPartnerPageView(eventName, params);
}, [eventName, params]);
}
+7 -2
View File
@@ -5,11 +5,16 @@ import { getRouterBasename } from '@dukang/weixin-sdk';
import App from './App';
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
import { PartnerToastProvider } from './contexts/PartnerToastContext';
import { installClientErrorReporting } from './lib/client-error';
import { installClientErrorReporting } from '@dukang/client-logging';
import { apiBase } from './lib/api';
import './styles.css';
import './styles/legal.css';
installClientErrorReporting();
installClientErrorReporting({
apiBase,
clientApp: 'PARTNER_H5',
getToken: () => localStorage.getItem('partnerAccessToken'),
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+2
View File
@@ -5,6 +5,7 @@ import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -25,6 +26,7 @@ function canConfirm(status: string) {
}
export default function BillsPage() {
usePartnerPageView('partner_bills_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [bills, setBills] = useState<PartnerBillDto[]>([]);
+2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { usePartnerPageView } from '../lib/usePageView';
import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import {
@@ -137,6 +138,7 @@ function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] })
}
export default function HomePage() {
usePartnerPageView('partner_home_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
@@ -6,6 +6,7 @@ import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isPrimaryAccount } from '../lib/partnerAccess';
import { usePartnerPageView } from '../lib/usePageView';
const PERIOD_TABS: { key: PartnerLeaderboardPeriod; label: string }[] = [
{ key: 'month', label: '本月' },
@@ -26,6 +27,7 @@ function periodSubLabel(period: PartnerLeaderboardPeriod): string {
}
export default function LeaderboardPage() {
usePartnerPageView('partner_leaderboard_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const showStaffFab = isPrimaryAccount(account);
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerPageView } from '../lib/usePageView';
const TIMELINE = [
{ key: 'confirm', label: '待确认' },
@@ -36,6 +37,7 @@ function canShip(status: string) {
export default function OrderDetailPage() {
const { id } = useParams();
usePartnerPageView('partner_order_detail_view', id ? { orderId: id } : undefined);
const navigate = useNavigate();
const [order, setOrder] = useState<Record<string, unknown> | null>(null);
const [shipping, setShipping] = useState(false);
@@ -5,6 +5,7 @@ import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { hasWarehouseAccess } from '../lib/partnerAccess';
import { usePartnerPageView } from '../lib/usePageView';
type DateFilter = 'today' | '7d' | '30d';
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
@@ -44,6 +45,7 @@ type OrdersResponse = {
};
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
usePartnerPageView('partner_order_list_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const warehouseOk = hasWarehouseAccess(account);
@@ -24,6 +24,7 @@ import type {
ProxyOrderPayResponse,
ProxyPayMethod,
} from '@dukang/shared-types';
import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -46,6 +47,7 @@ function initialDraft(): ProxyOrderDraft {
}
export default function ProxyOrderPage() {
usePartnerPageView('partner_proxy_order_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const draft0 = useMemo(() => initialDraft(), []);
@@ -9,8 +9,10 @@ import {
type PartnerStaffItem,
} from '@dukang/shared-types';
import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
import { usePartnerPageView } from '../lib/usePageView';
export default function StaffListPage() {
usePartnerPageView('partner_staff_list_view');
const navigate = useNavigate();
const [staff, setStaff] = useState<PartnerStaffItem[]>([]);
const [q, setQ] = useState('');
@@ -20,6 +20,7 @@ import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import { usePartnerPageView } from '../lib/usePageView';
import {
@@ -78,6 +79,8 @@ function isPhoneValidationMessage(message: string) {
export default function StoreCreatePage() {
usePartnerPageView('partner_store_create_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
@@ -16,6 +16,7 @@ import {
storeStatusPillClass,
type StoreStatusValue,
} from '../lib/storeStatus';
import { usePartnerPageView } from '../lib/usePageView';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
@@ -33,6 +34,7 @@ function uniqueEnvUrls(urls: string[]): string[] {
export default function StoreDetailPage() {
const { id } = useParams();
usePartnerPageView('partner_store_detail_view', id ? { storeId: id } : undefined);
const navigate = useNavigate();
const { account } = usePartnerSession();
const canMutate = canManagePartnerStore(account);
@@ -13,6 +13,7 @@ import {
storeStatusPillClass,
type StoreStatusValue,
} from '../lib/storeStatus';
import { usePartnerPageView } from '../lib/usePageView';
type StatusFilter = 'ALL' | StoreStatusValue | 'PENDING_AUDIT' | 'REJECTED';
@@ -26,6 +27,7 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
];
export default function StoreListPage() {
usePartnerPageView('partner_store_list_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession();
@@ -4,6 +4,7 @@ import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
import { usePartnerPageView } from '../lib/usePageView';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
@@ -15,6 +16,7 @@ function fmtGrowth(n: number) {
}
export default function WeeklyReportPage() {
usePartnerPageView('partner_weekly_report_view');
const navigate = useNavigate();
const [selectedStart, setSelectedStart] = useState<string | undefined>();
const [data, setData] = useState<PartnerWeeklyReportResponse | null>(null);
+1
View File
@@ -9,6 +9,7 @@
"lint": "echo ok"
},
"dependencies": {
"@dukang/client-logging": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
+26
View File
@@ -0,0 +1,26 @@
import { createStoreTracker } from '@dukang/client-logging';
import { apiBase } from './api';
const tracker = createStoreTracker({
apiBase,
clientApp: 'SHOP_H5',
getToken: () => localStorage.getItem('shopAccessToken'),
getStoreId: () => {
try {
const raw = localStorage.getItem('shopSession');
if (!raw) return null;
const parsed = JSON.parse(raw) as { storeId?: string };
return parsed.storeId ?? null;
} catch {
return null;
}
},
});
export function trackStore(eventName: string, params?: Record<string, unknown>) {
tracker.track(eventName, params);
}
export function trackStorePageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackStorePageView } from './analytics';
export function useStorePageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackStorePageView(eventName, params);
}, [eventName, params]);
}
+7 -2
View File
@@ -3,12 +3,17 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { getRouterBasename } from '@dukang/weixin-sdk';
import { StoreSessionProvider } from './contexts/StoreSessionContext';
import { installClientErrorReporting } from './lib/client-error';
import { installClientErrorReporting } from '@dukang/client-logging';
import App from './App';
import { apiBase } from './lib/api';
import './styles.css';
import './styles/legal.css';
installClientErrorReporting();
installClientErrorReporting({
apiBase,
clientApp: 'SHOP_H5',
getToken: () => localStorage.getItem('shopAccessToken'),
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+4
View File
@@ -14,6 +14,8 @@ import {
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import WechatScanAuthModal from '../components/WechatScanAuthModal';
import { useStorePageView } from '../lib/usePageView';
import { trackStore } from '../lib/analytics';
const PENDING_SCAN_KEY = 'shop_pending_scan';
@@ -33,6 +35,7 @@ function formatScanError(e: unknown): string {
}
export default function HomePage() {
useStorePageView('store_home_view');
const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
@@ -74,6 +77,7 @@ export default function HomePage() {
}, [loadDashboard]);
async function runScan(opts?: { postAuthWarmup?: boolean }) {
trackStore('store_redeem_scan_start');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
+2
View File
@@ -13,8 +13,10 @@ import {
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
import { useStorePageView } from '../lib/usePageView';
export default function MinePage() {
useStorePageView('store_mine_view');
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { resetSession, store: sessionStore, applySession } = useStoreSession();
+2
View File
@@ -4,6 +4,7 @@ import type { StorePackagesResponse } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import ShopPackagesForm from '../components/ShopPackagesForm';
import { request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
import {
emptyPackage,
normalizePackageFormItems,
@@ -12,6 +13,7 @@ import {
} from '../lib/storePackages';
export default function PackagesPage() {
useStorePageView('store_packages_view');
const navigate = useNavigate();
const [items, setItems] = useState<PackageFormItem[]>([emptyPackage()]);
const [pending, setPending] = useState<StorePackagesResponse['pendingRequest']>(null);
@@ -2,12 +2,14 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
import { request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function PhoneRedeemPage() {
useStorePageView('store_phone_redeem_view');
const navigate = useNavigate();
const [phone, setPhone] = useState('');
const [amount, setAmount] = useState('');
+2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
import { request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
type RangeKey = 'today' | '7d' | '30d';
type StatusFilter = 'all' | 'pending' | 'paid';
@@ -30,6 +31,7 @@ function channelLabel(channel: unknown): string {
}
export default function RecordsPage() {
useStorePageView('store_records_view');
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
const [range, setRange] = useState<RangeKey>('today');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
@@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
import { request } from '../lib/api';
import { reportRedeemFailure } from '../lib/redeem-failure';
import { useStorePageView } from '../lib/usePageView';
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -17,6 +18,7 @@ type Preview = {
};
export default function RedeemConfirmPage() {
useStorePageView('store_redeem_confirm_view');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [token, setToken] = useState('');
+2
View File
@@ -4,6 +4,7 @@ import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-typ
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
type StaffItem = {
id: string;
@@ -23,6 +24,7 @@ function maskPhone(phone: string) {
}
export default function StaffPage() {
useStorePageView('store_staff_view');
const navigate = useNavigate();
const { store } = useStoreSession();
const profile = store ?? getStoreProfile();
+2
View File
@@ -8,6 +8,7 @@ import {
} from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api';
import { useStorePageView } from '../lib/usePageView';
type StatusFilter = 'all' | StoreWithdrawStatus;
@@ -16,6 +17,7 @@ function formatMoney(n: number) {
}
export default function WithdrawPage() {
useStorePageView('store_withdraw_view');
const navigate = useNavigate();
const [summary, setSummary] = useState<StoreWithdrawSummaryDto | null>(null);
const [items, setItems] = useState<StoreWithdrawRequestDto[]>([]);
+1
View File
@@ -9,6 +9,7 @@
"lint": "echo ok"
},
"dependencies": {
"@dukang/client-logging": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
+17 -12
View File
@@ -1,16 +1,21 @@
import { createUserTracker, getSessionId } from '@dukang/client-logging';
import { apiBase } from './api';
export function track(eventName: string, params?: Record<string, unknown>) {
const token = localStorage.getItem('accessToken');
if (!token) return;
const tracker = createUserTracker({
apiBase,
clientApp: 'USER_H5',
});
void fetch(`${apiBase}/analytics/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-Client-App': 'USER_H5',
},
body: JSON.stringify({ events: [{ eventName, params }] }),
}).catch(() => {});
export { getSessionId };
export function track(eventName: string, params?: Record<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
export function initUserAnalytics() {
tracker.trackSessionStart();
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackPageView } from './analytics';
export function usePageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPageView(eventName, params);
}, [eventName, params]);
}
+6
View File
@@ -2,10 +2,16 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { getRouterBasename } from '@dukang/weixin-sdk';
import { installClientErrorReporting } from '@dukang/client-logging';
import App from './App';
import { initUserAnalytics } from './lib/analytics';
import { apiBase } from './lib/api';
import './styles.css';
import './styles/legal.css';
installClientErrorReporting({ apiBase, clientApp: 'USER_H5' });
initUserAnalytics();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter basename={getRouterBasename()}>
@@ -6,6 +6,7 @@ import { request } from '../lib/api';
import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation';
import { DEFAULT_REGION, formatRegion } from '../lib/region-data';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
import { usePageView } from '../lib/usePageView';
type AddressForm = {
receiverName: string;
@@ -21,6 +22,7 @@ export default function AddressEditPage() {
const { id } = useParams();
const [params] = useSearchParams();
const isEdit = Boolean(id);
usePageView('address_edit', { mode: isEdit ? 'edit' : 'create' });
const navigate = useNavigate();
const [pickerOpen, setPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
@@ -4,6 +4,7 @@ import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { useUserSession } from '../contexts/UserSessionContext';
import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation';
import { usePageView } from '../lib/usePageView';
type Address = {
id: string;
@@ -25,6 +26,7 @@ function formatAddress(a: Address) {
}
export default function AddressListPage() {
usePageView('address_list_view');
const { profile } = useUserSession();
const [list, setList] = useState<Address[]>([]);
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { usePageView } from '../lib/usePageView';
type TicketRow = {
id: string;
@@ -23,6 +24,7 @@ const STATUS_LABEL: Record<string, string> = {
};
export default function AfterSaleListPage() {
usePageView('after_sale_list_view');
const navigate = useNavigate();
const [items, setItems] = useState<TicketRow[]>([]);
const [loading, setLoading] = useState(true);
+3
View File
@@ -8,6 +8,8 @@ import {
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { uploadFileToOss } from '../lib/upload';
import { usePageView } from '../lib/usePageView';
import { track } from '../lib/analytics';
type OrderRow = {
id: string;
@@ -21,6 +23,7 @@ type OrderRow = {
const STEPS = ['类型', '订单', '凭证', '完成'] as const;
export default function AfterSalePage() {
usePageView('after_sale_apply');
const navigate = useNavigate();
const [params] = useSearchParams();
const presetOrderId = params.get('orderId') || '';
@@ -2,9 +2,11 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { usePageView } from '../lib/usePageView';
export default function BenefitDetailPage() {
const { id } = useParams();
usePageView('benefit_detail_view', { couponId: id });
const navigate = useNavigate();
const [data, setData] = useState<{ coupon: Record<string, unknown>; ledgers: Array<Record<string, unknown>> } | null>(null);
useEffect(() => {
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import BrandLogo from '@dukang/shared-ui/BrandLogo';
import { request } from '../lib/api';
import { usePageView } from '../lib/usePageView';
type BenefitSummary = {
totalBalance: number;
@@ -44,6 +45,7 @@ function buildRedeemUrl(coupon?: CouponItem) {
}
export default function BenefitPage() {
usePageView('benefit_page_view');
const navigate = useNavigate();
const listRef = useRef<HTMLElement>(null);
const [summary, setSummary] = useState<BenefitSummary | null>(null);
@@ -7,6 +7,7 @@ import { buildOrderAddressSelectUrl } from '../lib/navigation';
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
import { handleShareButtonClick } from '../lib/wechat-share';
import ContactCustomerSheet from '../components/ContactCustomerSheet';
import { usePageView } from '../lib/usePageView';
type OrderItem = {
productName: string;
@@ -131,6 +132,7 @@ function fullReceiverAddress(order: Order) {
export default function OrderDetailPage() {
const { id } = useParams();
usePageView('order_detail_view', { orderId: id });
const navigate = useNavigate();
const [order, setOrder] = useState<Order | null>(null);
const [showCs, setShowCs] = useState(false);
+2
View File
@@ -4,6 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader';
import AppImage from '@dukang/shared-ui/AppImage';
import OrderStatusTabs from '@dukang/shared-ui/OrderStatusTabs';
import { request } from '../lib/api';
import { usePageView } from '../lib/usePageView';
const TABS = [
{ key: 'all', label: '全部' },
@@ -36,6 +37,7 @@ function orderStatusLabel(order: Record<string, unknown>) {
export default function OrderListPage() {
const [params, setParams] = useSearchParams();
const tab = params.get('tab') || 'all';
usePageView('order_list_view', { tab });
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] });
const navigate = useNavigate();
+8
View File
@@ -15,6 +15,8 @@ import {
} from '../lib/pay-wechat';
import { applyWechatLoginResult, handleWechatAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
import { usePageView } from '../lib/usePageView';
import { track } from '../lib/analytics';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -32,6 +34,7 @@ async function waitOrderPaid(orderId: string, maxAttempts = 15) {
export default function PayPage() {
const [params] = useSearchParams();
const orderId = params.get('orderId') || '';
usePageView('pay_page_view', { orderId });
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
@@ -161,6 +164,11 @@ export default function PayPage() {
return;
}
setMsg(e instanceof Error ? e.message : '支付失败');
track('pay_fail', {
orderId,
failReason: e instanceof Error ? e.message : '支付失败',
pagePath: '/pay',
});
} finally {
setLoading(false);
}
+4 -1
View File
@@ -155,7 +155,10 @@ export default function ProductDetailPage() {
<button
type="button"
className="product-detail-buy-btn"
onClick={() => navigate(`/order/confirm?productId=${id}&qty=2`)}
onClick={() => {
track('product_click', { refType: 'PRODUCT', refId: id, productId: id });
navigate(`/order/confirm?productId=${id}&qty=2`);
}}
>
</button>
@@ -2,8 +2,10 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { usePageView } from '../lib/usePageView';
export default function RedeemCodePage() {
usePageView('redeem_code_view');
const navigate = useNavigate();
const token = sessionStorage.getItem('redeemToken') || '';
const amount = sessionStorage.getItem('redeemAmount') || '0';
+2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import { request } from '../lib/api';
import { usePageView } from '../lib/usePageView';
const POLL_INTERVAL_MS = 2500;
@@ -37,6 +38,7 @@ function formatTimer(seconds: number) {
}
export default function RedeemPage() {
usePageView('benefit_redeem_start');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const couponId = searchParams.get('couponId') ?? undefined;
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { usePageView } from '../lib/usePageView';
type RedeemRecord = {
id: string;
@@ -46,6 +47,7 @@ function StarRating({
}
export default function RedeemSuccessPage() {
usePageView('redeem_success_view');
const navigate = useNavigate();
const location = useLocation();
const [serviceScore, setServiceScore] = useState(5);
+1
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@babel/runtime": "^7.24.4",
"@dukang/client-logging": "workspace:*",
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
+22
View File
@@ -0,0 +1,22 @@
import { createUserTracker, getSessionId } from '@dukang/client-logging';
import { API_BASE, CLIENT_APP, getToken } from './api';
const tracker = createUserTracker({
apiBase: API_BASE,
clientApp: CLIENT_APP,
getToken,
});
export { getSessionId };
export function track(eventName: string, params?: Record<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
export function initUserAnalytics() {
tracker.trackSessionStart();
}
+11
View File
@@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
import { trackPageView } from './analytics';
export function usePageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPageView(eventName, params);
}, [eventName, params]);
}
+5
View File
@@ -33,6 +33,7 @@ import {
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import { trackPageView } from '../../lib/analytics';
type Product = {
id: string;
name: string;
@@ -79,6 +80,10 @@ export default function HomePage() {
const scrollLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastScrollSyncAtRef = useRef(0);
useEffect(() => {
trackPageView('home_view', { pagePath: '/pages/home/index', cityCode });
}, [cityCode]);
const loadMiniHome = useCallback(() => {
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
.then((cfg) => {
@@ -15,6 +15,7 @@ import {
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import { usePageView } from '../../lib/usePageView';
const isWeapp = process.env.TARO_ENV === 'weapp';
@@ -74,6 +75,7 @@ function fullReceiverAddress(order: OrderDetail) {
export default function OrderDetailPage() {
const router = useRouter();
const orderId = router.params.id ?? '';
usePageView('order_detail_view', orderId ? { orderId } : undefined);
const [order, setOrder] = useState<OrderDetail | null>(null);
const [confirming, setConfirming] = useState(false);
@@ -5,6 +5,7 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import { usePageView } from '../../lib/usePageView';
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
@@ -54,6 +55,7 @@ type OrderRow = {
export default function OrdersPage() {
const router = useRouter();
const [tab, setTab] = useState(() => normalizeOrdersTab(router.params.tab as string));
usePageView('order_list_view', { tab });
const [orders, setOrders] = useState<OrderRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -33,6 +33,7 @@ import {
toWeappShareMessage,
} from '../../lib/wechat-share';
import iconHome from '../../assets/tabbar/home.png';
import { usePageView } from '../../lib/usePageView';
type Product = ProductImageSource & {
id: string;
@@ -50,6 +51,10 @@ type Product = ProductImageSource & {
export default function ProductDetailPage() {
const router = useRouter();
const productId = router.params.id ?? '';
usePageView(
'product_detail_view',
productId ? { refType: 'PRODUCT', refId: productId, productId } : undefined,
);
const [product, setProduct] = useState<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false);