feat: 分析模块、接口管理及其他功能优化

- 后端: WebMvcConfig/拦截器/AnalyticsService/Mapper/测试优化,新增 Knife4jConfig、AnalyticsDictionary、数据库迁移脚本
- 前端: 分析仪表盘 UI 优化、接口管理列表及详情测试面板
- 小程序: analytics 服务优化、request 增强
- 文档: 分析模块中文标签设计文档、品牌重命名设计文档
- 部署: conf 配置优化、deploy.py 脚本更新

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 23:52:39 +08:00
parent d1a0018d1b
commit 9838e7626b
20 changed files with 1073 additions and 78 deletions
+9
View File
@@ -13,13 +13,20 @@ export interface AnalyticsOverview {
export interface AnalyticsTrendItem {
bucket: string
eventName: string
eventLabel?: string
count: number
users: number
}
export interface AnalyticsTopEvent {
eventName: string
eventLabel?: string
eventType: string
eventTypeLabel?: string
pagePath?: string
pageLabel?: string
apiPath?: string
apiLabel?: string
count: number
users: number
}
@@ -33,7 +40,9 @@ export interface AnalyticsFunnelItem {
export interface AnalyticsPreferenceItem {
dimension: string
dimensionLabel?: string
value: string
valueLabel?: string
count: number
users: number
}
@@ -3,44 +3,44 @@
<div class="dashboard-header">
<div>
<h2 class="page-title">行为分析</h2>
<p class="page-subtitle">小程序访问创作链路和朗读行为概览</p>
<p class="page-subtitle">查看小程序访问页面停留点击操作创作链路和接口调用情况</p>
</div>
<div class="header-actions">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="-"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
size="small"
/>
<el-button type="primary" size="small" :loading="loading" @click="fetchData">刷新</el-button>
<el-button type="primary" size="small" :loading="loading" @click="fetchData">刷新数据</el-button>
</div>
</div>
<el-row :gutter="16" class="stats-row" v-loading="loading">
<el-col v-for="card in cards" :key="card.label" :xs="12" :sm="8" :md="4">
<el-card class="stat-card">
<div class="stat-card">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-label">{{ card.label }}</div>
</el-card>
</div>
</el-col>
</el-row>
<el-row :gutter="16" class="panel-row">
<el-col :xs="24" :md="14">
<el-card class="panel-card">
<template #header>事件趋势</template>
<el-card class="panel-card" shadow="never">
<template #header>行为趋势</template>
<div ref="trendChartRef" class="chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="10">
<el-card class="panel-card">
<template #header>关键漏斗</template>
<el-table :data="funnel" size="small" height="320">
<el-card class="panel-card" shadow="never">
<template #header>创作关键漏斗</template>
<el-table :data="funnel" size="small" height="320" empty-text="暂无漏斗数据">
<el-table-column prop="label" label="步骤" min-width="150" show-overflow-tooltip />
<el-table-column prop="users" label="用户" width="90" />
<el-table-column prop="users" label="用户" width="90" />
<el-table-column label="转化率" width="100">
<template #default="{ row }">{{ formatPercent(row.conversionRate) }}</template>
</el-table-column>
@@ -49,27 +49,53 @@
</el-col>
</el-row>
<el-row :gutter="16" class="panel-row">
<el-col :xs="24" :md="14">
<el-card class="panel-card" shadow="never">
<template #header>热门用户行为</template>
<el-table :data="behaviorEvents" size="small" height="340" empty-text="暂无行为数据">
<el-table-column prop="eventLabel" label="行为名称" min-width="170" show-overflow-tooltip />
<el-table-column prop="pageLabel" label="所在页面" min-width="150" show-overflow-tooltip />
<el-table-column prop="eventTypeLabel" label="行为类型" width="110" />
<el-table-column prop="count" label="次数" width="90" />
<el-table-column prop="users" label="用户数" width="90" />
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :md="10">
<el-card class="panel-card" shadow="never">
<template #header>接口调用情况</template>
<el-table :data="apiEvents" size="small" height="340" empty-text="暂无接口调用数据">
<el-table-column prop="apiLabel" label="接口说明" min-width="160" show-overflow-tooltip />
<el-table-column prop="eventLabel" label="调用结果" width="120" />
<el-table-column prop="count" label="次数" width="90" />
<el-table-column prop="users" label="用户数" width="90" />
</el-table>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="panel-row">
<el-col :xs="24" :md="12">
<el-card class="panel-card">
<template #header>热门事件</template>
<el-table :data="topEvents" size="small" height="320">
<el-table-column prop="eventName" label="事件" min-width="170" show-overflow-tooltip />
<el-table-column prop="eventType" label="类型" width="110" />
<el-table-column prop="count" label="次数" width="95" />
<el-table-column prop="users" label="用户" width="95" />
<el-card class="panel-card" shadow="never">
<template #header>偏好分布</template>
<el-table :data="preferences" size="small" height="320" empty-text="暂无偏好数据">
<el-table-column prop="dimensionLabel" label="维度" width="130" show-overflow-tooltip />
<el-table-column prop="valueLabel" label="偏好值" min-width="170" show-overflow-tooltip />
<el-table-column prop="count" label="次数" width="90" />
<el-table-column prop="users" label="用户" width="90" />
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card class="panel-card">
<template #header>偏好分布</template>
<el-table :data="preferences" size="small" height="320">
<el-table-column prop="dimension" label="维度" width="110" show-overflow-tooltip />
<el-table-column prop="value" label="值" min-width="160" show-overflow-tooltip />
<el-table-column prop="count" label="次数" width="95" />
<el-table-column prop="users" label="用户" width="95" />
</el-table>
<el-card class="panel-card" shadow="never">
<template #header>页面与操作说明</template>
<div class="insight-list">
<div v-for="item in insightItems" :key="item.title" class="insight-item">
<div class="insight-title">{{ item.title }}</div>
<div class="insight-desc">{{ item.desc }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
@@ -113,12 +139,30 @@ const trendChartRef = ref<HTMLDivElement>()
let trendChart: echarts.ECharts | null = null
const cards = computed(() => [
{ label: 'PV', value: overview.value.pv.toLocaleString() },
{ label: 'UV', value: overview.value.uv.toLocaleString() },
{ label: '事件数', value: overview.value.eventCount.toLocaleString() },
{ label: '活跃用户', value: overview.value.activeUsers.toLocaleString() },
{ label: '朗读请求', value: overview.value.ttsRequests.toLocaleString() },
{ label: '平均停留', value: `${Math.round((overview.value.avgStayMs || 0) / 1000)}s` }
{ label: '页面浏览量', value: overview.value.pv.toLocaleString() },
{ label: '访问用户数', value: overview.value.uv.toLocaleString() },
{ label: '行为事件数', value: overview.value.eventCount.toLocaleString() },
{ label: '活跃用户', value: overview.value.activeUsers.toLocaleString() },
{ label: '朗读请求', value: overview.value.ttsRequests.toLocaleString() },
{ label: '平均停留时长', value: formatDuration(overview.value.avgStayMs || 0) }
])
const behaviorEvents = computed(() => topEvents.value.filter(item => item.eventType !== 'api'))
const apiEvents = computed(() => topEvents.value.filter(item => item.eventType === 'api' || item.eventName.startsWith('api_request_')))
const insightItems = computed(() => [
{
title: '行为名称',
desc: '所有事件都显示为中文业务动作,例如“浏览页面”“提交创作愿望”“剧本生成成功”。'
},
{
title: '所在页面',
desc: '页面路径统一转换为小程序中的中文页面名称,例如“首页”“爽文生成页”“剧本详情页”。'
},
{
title: '接口说明',
desc: '接口调用按中文用途展示,例如“AI 流式生成”“创建朗读任务”“查询人生事件”。'
}
])
const queryParams = computed<AnalyticsQuery>(() => {
@@ -132,6 +176,14 @@ const queryParams = computed<AnalyticsQuery>(() => {
const formatPercent = (value: number) => `${Math.round((value || 0) * 100)}%`
const formatDuration = (ms: number) => {
const seconds = Math.round(ms / 1000)
if (seconds < 60) return `${seconds}`
const minutes = Math.floor(seconds / 60)
const rest = seconds % 60
return rest ? `${minutes}${rest}` : `${minutes}分钟`
}
const responseData = <T,>(response: unknown, fallback: T): T => {
const payload = response as { data?: T }
return payload.data ?? fallback
@@ -143,8 +195,9 @@ const renderTrend = async () => {
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
const buckets = [...new Set(trend.value.map(item => item.bucket))]
const eventNames = [...new Set(trend.value.map(item => item.eventName))].slice(0, 5)
const labelByEvent = new Map(trend.value.map(item => [item.eventName, item.eventLabel || item.eventName]))
const series = eventNames.map(eventName => ({
name: eventName,
name: labelByEvent.get(eventName) || eventName,
type: 'line',
smooth: true,
data: buckets.map(bucket => trend.value.find(item => item.bucket === bucket && item.eventName === eventName)?.count || 0)
@@ -167,7 +220,7 @@ const fetchData = async () => {
const [overviewRes, trendRes, topRes, funnelRes, preferenceRes] = await Promise.all([
getAnalyticsOverview(params),
getAnalyticsTrend(params),
getAnalyticsTopEvents({ ...params, limit: 20 }),
getAnalyticsTopEvents({ ...params, limit: 60 }),
getAnalyticsFunnel(params),
getAnalyticsPreferences({ ...params, limit: 20 })
])
@@ -233,6 +286,12 @@ onBeforeUnmount(() => {
.stat-card {
min-height: 96px;
padding: 18px 20px;
border: 1px solid var(--ls-glass-border);
border-radius: var(--ls-radius-lg);
background: rgba(15, 17, 26, 0.38);
box-shadow: var(--ls-shadow);
backdrop-filter: blur(20px) saturate(160%);
}
.stat-value {
@@ -257,5 +316,31 @@ onBeforeUnmount(() => {
height: 320px;
width: 100%;
}
.insight-list {
display: grid;
gap: 12px;
min-height: 276px;
align-content: start;
}
.insight-item {
padding: 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--ls-radius-md);
background: rgba(0, 0, 0, 0.16);
}
.insight-title {
color: var(--ls-text);
font-weight: 700;
margin-bottom: 6px;
}
.insight-desc {
color: rgba(226, 232, 240, 0.62);
font-size: 13px;
line-height: 1.7;
}
}
</style>
@@ -65,15 +65,69 @@
<el-form-item label="请求方法">
<el-input v-model="testForm.method" disabled />
</el-form-item>
<el-form-item v-for="param in (detail?.params || [])" :key="param.name" :label="param.name">
<el-input v-model="testForm.params[param.name]" :placeholder="param.description || param.example || ''" />
</el-form-item>
<template v-for="param in queryParams" :key="param.name">
<el-form-item :label="param.name">
<!-- 枚举类型下拉 -->
<el-select
v-if="getParamInputType(param) === 'select'"
v-model="testForm.params[param.name]"
:placeholder="param.description || param.example || ''"
clearable
>
<el-option
v-for="opt in getEnumOptions(param)"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<!-- 布尔类型开关 -->
<el-switch
v-else-if="getParamInputType(param) === 'switch'"
v-model="testForm.params[param.name]"
active-value="true"
inactive-value="false"
/>
<!-- 数字类型 -->
<el-input
v-else-if="getParamInputType(param) === 'number'"
v-model="testForm.params[param.name]"
type="number"
:placeholder="param.description || param.example || ''"
/>
<!-- 默认文本 -->
<el-input
v-else
v-model="testForm.params[param.name]"
:placeholder="param.description || param.example || ''"
/>
</el-form-item>
</template>
<el-form-item v-if="['POST', 'PUT', 'PATCH'].includes(testForm.method)" label="请求体">
<el-input v-model="testForm.body" type="textarea" :rows="5" placeholder="JSON 格式" />
</el-form-item>
<div v-if="testForm.body" class="body-hint">
<el-icon><InfoFilled /></el-icon>
参数结构已预填充修改值后点击"发送请求"
</div>
</el-form>
</el-card>
<!-- 请求头 -->
<el-card class="headers-card" style="margin-bottom: 16px">
<template #header>请求头</template>
<div class="headers-list">
<div class="header-item">
<span class="header-key">Authorization:</span>
<span class="header-value">{{ currentToken ? `Bearer ${currentToken.slice(0, 20)}...` : '未设置' }}</span>
</div>
<div v-if="['POST', 'PUT', 'PATCH'].includes(testForm.method) && testForm.body" class="header-item">
<span class="header-key">Content-Type:</span>
<span class="header-value">application/json</span>
</div>
</div>
</el-card>
<el-button type="primary" @click="handleTest" :loading="testing">发送请求</el-button>
<!-- 响应结果 -->
@@ -98,11 +152,13 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { InfoFilled } from '@element-plus/icons-vue'
import { testEndpoint, type ApiEndpointDetail } from '@/api/endpoint'
const props = defineProps<{
modelValue: boolean
detail: ApiEndpointDetail | null
defaultTab?: 'detail' | 'test'
}>()
const emit = defineEmits<{
@@ -127,6 +183,76 @@ const testForm = ref({
body: ''
})
const queryParams = computed(() => {
return (props.detail?.params || []).filter(p => p.paramType === 'query' || p.paramType === 'path')
})
const currentToken = computed(() => {
if (tokenSource.value === 'admin') {
return localStorage.getItem('adminToken') || ''
}
return manualToken.value
})
const getParamInputType = (param: any): string => {
if (param.enumValues) return 'select'
const type = (param.paramTypeDef || '').toLowerCase()
if (type === 'boolean') return 'switch'
if (type === 'integer' || type === 'number') return 'number'
return 'text'
}
const getEnumOptions = (param: any): { label: string; value: string }[] => {
if (!param.enumValues) return []
return param.enumValues.split(',').map((v: string) => ({ label: v.trim(), value: v.trim() }))
}
const generateJsonTemplate = (schema: any): any => {
if (!schema || !schema.properties) return {}
const result: any = {}
for (const [key, prop] of Object.entries(schema.properties)) {
const p = prop as any
if (p.example && p.example !== '' && p.example !== 'null') {
try { result[key] = JSON.parse(p.example) }
catch { result[key] = p.example }
} else if (p.type === 'string') {
result[key] = ''
} else if (p.type === 'integer') {
result[key] = 0
} else if (p.type === 'number') {
result[key] = 0
} else if (p.type === 'boolean') {
result[key] = false
} else if (p.type === 'array') {
result[key] = []
} else if (p.type === 'object' && p.properties) {
result[key] = generateJsonTemplate(p)
} else {
result[key] = null
}
}
const required = schema.required || []
for (const k of Object.keys(result)) {
if (!required.includes(k) && (result[k] === null || result[k] === '' || result[k] === 0 || result[k] === false || (Array.isArray(result[k]) && result[k].length === 0))) {
delete result[k]
}
}
return result
}
const generateRequestBody = () => {
if (!props.detail?.requestSchema || props.detail.requestSchema === '{}') {
testForm.value.body = ''
return
}
try {
const schema = JSON.parse(props.detail.requestSchema)
testForm.value.body = JSON.stringify(generateJsonTemplate(schema), null, 2)
} catch {
testForm.value.body = ''
}
}
watch(() => props.detail, (ep) => {
if (ep) {
testForm.value.path = ep.path
@@ -134,7 +260,8 @@ watch(() => props.detail, (ep) => {
testForm.value.params = {}
testForm.value.body = ''
testResult.value = null
activeTab.value = 'detail'
generateRequestBody()
activeTab.value = props.defaultTab || 'detail'
}
}, { immediate: true })
@@ -168,9 +295,9 @@ const handleTest = async () => {
headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`
}
const queryParams: Record<string, string> = {}
const params: Record<string, string> = {}
for (const [key, value] of Object.entries(testForm.value.params)) {
if (value) queryParams[key] = value
if (value) params[key] = value
}
const res: any = await testEndpoint({
@@ -178,7 +305,7 @@ const handleTest = async () => {
path: '/api' + testForm.value.path,
body: testForm.value.body || undefined,
headers,
params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
params: Object.keys(params).length > 0 ? params : undefined,
timeoutSeconds: 30
})
@@ -232,4 +359,38 @@ const handleTest = async () => {
font-family: 'Menlo', 'Monaco', monospace;
font-size: 13px;
}
.body-hint {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
display: flex;
align-items: center;
gap: 4px;
}
.headers-card {
:deep(.el-card__header) {
padding: 8px 16px;
}
}
.headers-list {
.header-item {
display: flex;
gap: 8px;
font-size: 13px;
font-family: 'Menlo', 'Monaco', monospace;
.header-key {
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.header-value {
color: var(--el-text-color-primary);
word-break: break-all;
}
}
}
</style>
+17 -2
View File
@@ -53,9 +53,10 @@
<el-tag v-if="row.deprecated === 1" type="danger" size="small">废弃</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<el-table-column label="操作" width="150" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
<el-button type="success" link size="small" @click="showTest(row)">测试</el-button>
</template>
</el-table-column>
</el-table>
@@ -74,7 +75,7 @@
</el-card>
<!-- 详情弹窗 -->
<EndpointDetailDialog v-model="detailVisible" :detail="selectedDetail" />
<EndpointDetailDialog v-model="detailVisible" :detail="selectedDetail" :default-tab="defaultTab" />
</div>
</template>
@@ -89,6 +90,7 @@ const loading = ref(false)
const syncing = ref(false)
const detailVisible = ref(false)
const selectedDetail = ref<ApiEndpointDetail | null>(null)
const defaultTab = ref<'detail' | 'test'>('detail')
const searchForm = reactive({
keyword: '',
@@ -149,6 +151,7 @@ const handleSync = async () => {
const showDetail = async (row: ApiEndpointItem) => {
selectedDetail.value = null
defaultTab.value = 'detail'
detailVisible.value = true
try {
const res: any = await getEndpointDetail(row.operationId)
@@ -158,6 +161,18 @@ const showDetail = async (row: ApiEndpointItem) => {
}
}
const showTest = async (row: ApiEndpointItem) => {
selectedDetail.value = null
detailVisible.value = true
defaultTab.value = 'test'
try {
const res: any = await getEndpointDetail(row.operationId)
selectedDetail.value = res.data
} catch {
ElMessage.error('加载详情失败')
}
}
const getMethodType = (method: string): string => {
const types: Record<string, string> = {
GET: 'success',