feat(adtracking): 新增推广链接追踪与数据大盘,支持自定义短码、注册与下单归因及二维码物料生成

1. 数据库新增 000064_ad_tracking.sql 迁移,创建 ad_tracking_links 和 ad_tracking_clicks 表,并在 users、rental_orders 增加归因字段
2. 后端新增 adtracking 模块,挂载 GET /r/:code 302 智能重定向、点击流水异步记录、转化率/GMV聚合统计及管理接口
3. 用户认证 (auth) 与订单创建 (order) 链路贯通,实现注册用户与订单成交推广短码全链路归因
4. 前端管理后台左侧新增【推广追踪】目录,支持大盘指标总览、推广链接 CRUD、状态启停、一键复制短链及二维码下载 (PNG)
This commit is contained in:
yml2213
2026-09-10 11:05:47 +08:00
parent 33ee8408e9
commit 2b11d70ebe
25 changed files with 2304 additions and 36 deletions
@@ -0,0 +1,110 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
export interface TrackingLinkItem {
id: number
name: string
code: string
channel_type: string
target_url: string
short_url: string
status: 'active' | 'disabled'
cost_cent: number
notes: string
created_by: number
created_at: string
updated_at: string
click_pv: number
click_uv: number
click_ip: number
register_count: number
order_count: number
paid_order_count: number
paid_gmv_cent: number
conversion_rate: number
}
export interface TrackingOverviewStats {
total_links: number
active_links: number
total_pv: number
total_uv: number
total_ip: number
total_registers: number
total_orders: number
total_paid_orders: number
total_paid_gmv_cent: number
today_pv: number
today_uv: number
today_registers: number
today_orders: number
today_paid_gmv_cent: number
}
export interface ListTrackingLinksParams {
keyword?: string
channel_type?: string
status?: string
page?: number
page_size?: number
}
export interface CreateTrackingLinkPayload {
name: string
code: string
channel_type?: string
target_url?: string
cost_cent?: number
notes?: string
}
export interface UpdateTrackingLinkPayload {
name: string
channel_type?: string
target_url?: string
cost_cent?: number
notes?: string
}
export async function fetchTrackingOverview(): Promise<TrackingOverviewStats> {
const { data } = await apiClient.get<ApiResponse<TrackingOverviewStats>>(
'/admin/tracking-links/overview'
)
return data.data
}
export async function fetchTrackingLinks(
params: ListTrackingLinksParams
): Promise<{ items: TrackingLinkItem[]; total: number }> {
const { data } = await apiClient.get<ApiResponse<{ items: TrackingLinkItem[]; total: number }>>(
'/admin/tracking-links',
{ params }
)
return data.data
}
export async function createTrackingLink(
payload: CreateTrackingLinkPayload
): Promise<TrackingLinkItem> {
const { data } = await apiClient.post<ApiResponse<TrackingLinkItem>>(
'/admin/tracking-links',
payload
)
return data.data
}
export async function updateTrackingLink(
id: number,
payload: UpdateTrackingLinkPayload
): Promise<void> {
await apiClient.put(`/admin/tracking-links/${id}`, payload)
}
export async function updateTrackingLinkStatus(
id: number,
status: 'active' | 'disabled'
): Promise<void> {
await apiClient.put(`/admin/tracking-links/${id}/status`, { status })
}
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -1,6 +1,7 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
import type { RealnameStatusValue, RiskStatus, UserStatus } from '@/shared/types/status'
import { getTrackCode } from '@/shared/utils/trackCode'
export interface AuthUser {
id: number
@@ -51,7 +52,12 @@ export async function sendSmsCode(phone: string, captchaId: string, captchaCode:
}
export async function loginWithSms(phone: string, code: string) {
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/sms/login', { phone, code })
const trackCode = getTrackCode()
const payload: Record<string, unknown> = { phone, code }
if (trackCode) {
payload.track_code = trackCode
}
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/sms/login', payload)
return data.data
}
@@ -64,11 +70,12 @@ export async function loginWithPassword(phone: string, password: string) {
}
export async function registerWithPassword(phone: string, code: string, password: string) {
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/password/register', {
phone,
code,
password,
})
const trackCode = getTrackCode()
const payload: Record<string, unknown> = { phone, code, password }
if (trackCode) {
payload.track_code = trackCode
}
const { data } = await apiClient.post<ApiResponse<LoginData>>('/auth/password/register', payload)
return data.data
}
+7 -1
View File
@@ -2,6 +2,7 @@ import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
import type { HandoffStatus, OrderStatus, SettlementStatus } from '@/shared/types/status'
import { yuanToCent } from '@/shared/utils/money'
import { getTrackCode } from '@/shared/utils/trackCode'
export interface Order {
id: number
@@ -280,7 +281,12 @@ export async function fetchOrder(id: string | number) {
}
export async function createOrder(listingId: number) {
const { data } = await apiClient.post<ApiResponse<Order>>('/orders', { listing_id: listingId })
const trackCode = getTrackCode()
const payload: Record<string, unknown> = { listing_id: listingId }
if (trackCode) {
payload.track_code = trackCode
}
const { data } = await apiClient.post<ApiResponse<Order>>('/orders', payload)
return data.data
}
+15
View File
@@ -16,6 +16,8 @@ import {
Operation,
Picture,
Present,
Promotion,
Link,
ScaleToOriginal,
Service,
Shop,
@@ -131,6 +133,19 @@ const allNavGroups: NavGroup[] = [
},
],
},
{
label: '推广追踪',
index: 'ad-tracking',
icon: Promotion,
children: [
{
label: '推广链接管理',
to: adminPath('tracking-links'),
icon: Link,
permission: 'ad_tracking:manage',
},
],
},
{
label: '客服与风控',
index: 'support-risk',
+6
View File
@@ -216,4 +216,10 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminAnnouncementsView.vue'),
meta: adminMeta,
},
{
path: adminPath('tracking-links'),
name: 'admin-tracking-links',
component: () => import('@/features/admin/views/AdminTrackingLinksView.vue'),
meta: adminMeta,
},
]
+5
View File
@@ -10,6 +10,7 @@ import { isMobileHost, isMobileDevice, getMobilePath, getPcPath } from './mobile
import { mobileRoutes } from './mobileRoutes'
import { publicRoutes } from './publicRoutes'
import { sellerRoutes } from './sellerRoutes'
import { saveTrackCode } from '@/shared/utils/trackCode'
const router = createRouter({
history: createWebHistory(),
@@ -27,6 +28,10 @@ const router = createRouter({
})
router.beforeEach(async to => {
if (to.query.track_code) {
saveTrackCode(String(to.query.track_code))
}
const isMobile = isMobileHost() || isMobileDevice()
if (isMobile) {
+46
View File
@@ -0,0 +1,46 @@
const TRACK_CODE_KEY = 'hfb_track_code'
const TRACK_CODE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天有效
interface StoredTrackCode {
code: string
timestamp: number
}
export function saveTrackCode(code: string): void {
if (!code) return
const cleanCode = code.trim()
if (!cleanCode) return
try {
const item: StoredTrackCode = {
code: cleanCode,
timestamp: Date.now(),
}
localStorage.setItem(TRACK_CODE_KEY, JSON.stringify(item))
} catch {
// 忽略 localStorage 存取异常
}
}
export function getTrackCode(): string {
try {
const raw = localStorage.getItem(TRACK_CODE_KEY)
if (!raw) return ''
const item: StoredTrackCode = JSON.parse(raw)
if (!item || !item.code) return ''
if (Date.now() - item.timestamp > TRACK_CODE_TTL_MS) {
localStorage.removeItem(TRACK_CODE_KEY)
return ''
}
return item.code
} catch {
return ''
}
}
export function clearTrackCode(): void {
try {
localStorage.removeItem(TRACK_CODE_KEY)
} catch {
// 忽略
}
}