feat(analytics): 前端板块骨架——echarts按需/useChart/路由/侧栏组/API模块
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Generated
+33
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.19.0",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
@@ -1045,6 +1046,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/element-plus": {
|
||||
"version": "2.14.3",
|
||||
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.3.tgz",
|
||||
@@ -1756,6 +1773,7 @@
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2221,6 +2239,21 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.19.0",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// 数据分析板块 API(2026-09-04)。client.ts 的 http 已带 Bearer + baseURL=/api,
|
||||
// getJson/requestJson 负责解包与泛型。Page/Channel 沿用 types.ts 现成定义。
|
||||
import { getJson, requestJson } from './client'
|
||||
import type { Channel, Page } from './types'
|
||||
|
||||
export type { Channel }
|
||||
export type Scope = 'today' | 'week' | 'month'
|
||||
|
||||
export interface OverviewCards {
|
||||
msg_total: number
|
||||
active_customers: number
|
||||
active_staff: number
|
||||
unmapped_accounts: number
|
||||
call_cnt: number
|
||||
first_contact: number
|
||||
prev_msg_total?: number
|
||||
msg_total_delta?: number | null
|
||||
}
|
||||
export interface TrendPoint { day: number; msg: number; active_customers: number; call_cnt: number }
|
||||
export interface AnomalyStaff { user_id: string; yesterday: number; prev7_mean: number; new?: boolean }
|
||||
export interface Anomaly { total_delta_pct: number; yesterday_total: number; prev7_mean: number; staff: AnomalyStaff[] }
|
||||
export interface OverviewResp {
|
||||
scope: Scope
|
||||
channel: Channel
|
||||
days: number[]
|
||||
cards: OverviewCards
|
||||
trend: TrendPoint[]
|
||||
anomaly: Anomaly | null
|
||||
}
|
||||
export interface HealthResp { data_as_of: number | null; entities: { table: string; last_seen: string | null }[] }
|
||||
export interface ChurnRow {
|
||||
friend_id: string; channel: string; nickname: string; owner_account_id: string | null
|
||||
user_id: string | null; last_chat_time: number | null; hist_msgs: number; active_days: number
|
||||
}
|
||||
export interface UnrepliedRow {
|
||||
peer_id: string; channel: string; owner_account_id: string; last_ts: number
|
||||
wait_ms: number; preview: string; msg_id: string
|
||||
}
|
||||
export interface SensitiveWordRow {
|
||||
id: number; word: string; category: string | null; enabled: boolean
|
||||
min_len: number; exclude_words: string[]
|
||||
}
|
||||
export interface SensitiveHitRow {
|
||||
id: number; word_id: number; word: string; category: string | null; msg_id: string
|
||||
channel: string; owner_account_id: string | null; talker_id: string | null
|
||||
room_id: string | null; ts: number | null; snippet: string | null; disposition: string
|
||||
}
|
||||
|
||||
export const getOverview = (channel: Channel, scope: Scope) =>
|
||||
getJson<OverviewResp>('/analytics/overview', { channel, scope })
|
||||
export const getAnalyticsHealth = () => getJson<HealthResp>('/analytics/health')
|
||||
export const getChurn = (params: Record<string, unknown>) =>
|
||||
getJson<Page<ChurnRow>>('/analytics/churn', params)
|
||||
export const getUnreplied = (params: Record<string, unknown>) =>
|
||||
getJson<Page<UnrepliedRow>>('/analytics/unreplied', params)
|
||||
export const getWords = () => getJson<SensitiveWordRow[]>('/analytics/sensitive/words')
|
||||
export const previewWord = (data: { word: string; exclude_words?: string[] }) =>
|
||||
requestJson<{ count: number; warn: boolean }>('post', '/analytics/sensitive/words/preview', data)
|
||||
export const createWord = (data: { word: string; category?: string; exclude_words?: string[] }) =>
|
||||
requestJson<{ id: number; word: string; enabled: boolean }>('post', '/analytics/sensitive/words', data)
|
||||
export const patchWord = (id: number, data: Record<string, unknown>) =>
|
||||
requestJson<Record<string, unknown>>('patch', `/analytics/sensitive/words/${id}`, data)
|
||||
export const deleteWord = (id: number) =>
|
||||
requestJson<{ ok: boolean }>('delete', `/analytics/sensitive/words/${id}`)
|
||||
export const getHits = (params: Record<string, unknown>) =>
|
||||
getJson<Page<SensitiveHitRow>>('/analytics/sensitive/hits', params)
|
||||
export const disposeHits = (ids: number[], disposition: string) =>
|
||||
requestJson<{ updated: number }>('patch', '/analytics/sensitive/hits', { ids, disposition })
|
||||
@@ -0,0 +1,35 @@
|
||||
// ECharts 按需封装(2026-09-04):注册/主题/自适应/销毁一处管——后续所有
|
||||
// 图表页复用(二批 A2/A4/A5 直接 import 本 composable)。
|
||||
import { onBeforeUnmount, type Ref } from 'vue'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart, BarChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import type { EChartsCoreOption } from 'echarts/core'
|
||||
|
||||
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
export function useChart(el: Ref<HTMLElement | null>) {
|
||||
let chart: echarts.ECharts | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
const render = (option: EChartsCoreOption) => {
|
||||
if (!el.value) return
|
||||
if (!chart) {
|
||||
chart = echarts.init(el.value)
|
||||
ro = new ResizeObserver(() => chart?.resize())
|
||||
ro.observe(el.value)
|
||||
}
|
||||
chart.setOption(option, true) // notMerge:指标切换时丢弃旧系列
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect()
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
|
||||
return { render }
|
||||
}
|
||||
|
||||
export { echarts }
|
||||
@@ -98,6 +98,25 @@ function logout(): void {
|
||||
<el-menu-item index="/search" @click="goPlain('/search')">
|
||||
全局搜索
|
||||
</el-menu-item>
|
||||
|
||||
<!-- 数据分析 -->
|
||||
<el-sub-menu index="analytics">
|
||||
<template #title>
|
||||
<span class="yk-sidenav__group">数据分析</span>
|
||||
</template>
|
||||
<el-menu-item index="/analytics/dashboard" @click="goPlain('/analytics/dashboard')">
|
||||
数据看板
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/analytics/churn" @click="goPlain('/analytics/churn')">
|
||||
流失预警
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/analytics/unreplied" @click="goPlain('/analytics/unreplied')">
|
||||
该回未回
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/analytics/sensitive" @click="goPlain('/analytics/sensitive')">
|
||||
敏感词
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
<div class="yk-sidenav__footer">
|
||||
<el-button text class="yk-sidenav__logout" @click="logout">
|
||||
|
||||
@@ -67,6 +67,31 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/SearchView.vue'),
|
||||
meta: { title: '搜索' },
|
||||
},
|
||||
{
|
||||
// 数据分析板块(spec 2026-09-04):四页
|
||||
path: '/analytics/dashboard',
|
||||
name: 'analytics-dashboard',
|
||||
component: () => import('@/views/analytics/DashboardView.vue'),
|
||||
meta: { title: '数据看板' },
|
||||
},
|
||||
{
|
||||
path: '/analytics/churn',
|
||||
name: 'analytics-churn',
|
||||
component: () => import('@/views/analytics/ChurnView.vue'),
|
||||
meta: { title: '流失预警' },
|
||||
},
|
||||
{
|
||||
path: '/analytics/unreplied',
|
||||
name: 'analytics-unreplied',
|
||||
component: () => import('@/views/analytics/UnrepliedView.vue'),
|
||||
meta: { title: '该回未回' },
|
||||
},
|
||||
{
|
||||
path: '/analytics/sensitive',
|
||||
name: 'analytics-sensitive',
|
||||
component: () => import('@/views/analytics/SensitiveView.vue'),
|
||||
meta: { title: '敏感词' },
|
||||
},
|
||||
{
|
||||
path: '/chat/:channel/:ownerId/:talkerId?',
|
||||
name: 'chat',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2>流失预警</h2>
|
||||
<p>实现于后续任务</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2>数据看板</h2>
|
||||
<p>实现于后续任务</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2>敏感词</h2>
|
||||
<p>实现于后续任务</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2>该回未回</h2>
|
||||
<p>实现于后续任务</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
Reference in New Issue
Block a user