From 5fb19dd3acebc7c8ec32640dc77bd0bd22fb39e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 4 Sep 2026 21:24:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=E5=89=8D=E7=AB=AF=E6=9D=BF?= =?UTF-8?q?=E5=9D=97=E9=AA=A8=E6=9E=B6=E2=80=94=E2=80=94echarts=E6=8C=89?= =?UTF-8?q?=E9=9C=80/useChart/=E8=B7=AF=E7=94=B1/=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E7=BB=84/API=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- yunkes/package-lock.json | 33 ++++++++++ yunkes/package.json | 1 + yunkes/src/api/analytics.ts | 68 ++++++++++++++++++++ yunkes/src/composables/useChart.ts | 35 ++++++++++ yunkes/src/layouts/MainLayout.vue | 19 ++++++ yunkes/src/router/index.ts | 25 +++++++ yunkes/src/views/analytics/ChurnView.vue | 8 +++ yunkes/src/views/analytics/DashboardView.vue | 8 +++ yunkes/src/views/analytics/SensitiveView.vue | 8 +++ yunkes/src/views/analytics/UnrepliedView.vue | 8 +++ 10 files changed, 213 insertions(+) create mode 100644 yunkes/src/api/analytics.ts create mode 100644 yunkes/src/composables/useChart.ts create mode 100644 yunkes/src/views/analytics/ChurnView.vue create mode 100644 yunkes/src/views/analytics/DashboardView.vue create mode 100644 yunkes/src/views/analytics/SensitiveView.vue create mode 100644 yunkes/src/views/analytics/UnrepliedView.vue diff --git a/yunkes/package-lock.json b/yunkes/package-lock.json index 3030da3..22e0453 100644 --- a/yunkes/package-lock.json +++ b/yunkes/package-lock.json @@ -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" } } } diff --git a/yunkes/package.json b/yunkes/package.json index bf3c121..9a229b9 100644 --- a/yunkes/package.json +++ b/yunkes/package.json @@ -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", diff --git a/yunkes/src/api/analytics.ts b/yunkes/src/api/analytics.ts new file mode 100644 index 0000000..1da6544 --- /dev/null +++ b/yunkes/src/api/analytics.ts @@ -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('/analytics/overview', { channel, scope }) +export const getAnalyticsHealth = () => getJson('/analytics/health') +export const getChurn = (params: Record) => + getJson>('/analytics/churn', params) +export const getUnreplied = (params: Record) => + getJson>('/analytics/unreplied', params) +export const getWords = () => getJson('/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) => + requestJson>('patch', `/analytics/sensitive/words/${id}`, data) +export const deleteWord = (id: number) => + requestJson<{ ok: boolean }>('delete', `/analytics/sensitive/words/${id}`) +export const getHits = (params: Record) => + getJson>('/analytics/sensitive/hits', params) +export const disposeHits = (ids: number[], disposition: string) => + requestJson<{ updated: number }>('patch', '/analytics/sensitive/hits', { ids, disposition }) diff --git a/yunkes/src/composables/useChart.ts b/yunkes/src/composables/useChart.ts new file mode 100644 index 0000000..4bf6e7d --- /dev/null +++ b/yunkes/src/composables/useChart.ts @@ -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) { + 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 } diff --git a/yunkes/src/layouts/MainLayout.vue b/yunkes/src/layouts/MainLayout.vue index 23041f1..9642028 100644 --- a/yunkes/src/layouts/MainLayout.vue +++ b/yunkes/src/layouts/MainLayout.vue @@ -98,6 +98,25 @@ function logout(): void { 全局搜索 + + + + + + 数据看板 + + + 流失预警 + + + 该回未回 + + + 敏感词 + +