Skip to content

Commit 7c096f6

Browse files
committed
feat: add dashboard summary statistics ui
- add statistics page AI summary card with auto-generate and markdown rendering - introduce dashboard summary query/composable and markdown sanitization utility - add marked and dompurify dependencies for summary rendering - raise dashboard summary generate request timeout to 65000ms and cover it with tests
1 parent e3cb893 commit 7c096f6

8 files changed

Lines changed: 414 additions & 7 deletions

File tree

apps/web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
"dependencies": {
1414
"@subtracker/shared": "0.0.1",
1515
"@tanstack/vue-query": "^5.74.9",
16+
"@types/dompurify": "^3.0.5",
1617
"@vicons/ionicons5": "^0.13.0",
1718
"@vueuse/core": "^13.0.0",
1819
"axios": "^1.8.4",
1920
"dayjs": "^1.11.13",
21+
"dompurify": "^3.4.2",
2022
"echarts": "^5.6.0",
2123
"fflate": "^0.8.2",
24+
"marked": "^18.0.3",
2225
"naive-ui": "^2.41.0",
2326
"pinia": "^3.0.2",
2427
"sql.js": "^1.14.1",

apps/web/src/composables/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import axios from 'axios'
22
import type {
3+
AiDashboardSummary,
34
AiRecognitionResult,
45
AiTestResponse,
56
AuthResponse,
@@ -41,6 +42,7 @@ const client = axios.create({
4142
})
4243

4344
const LOGO_REQUEST_TIMEOUT_MS = 60000
45+
const DASHBOARD_AI_SUMMARY_GENERATE_TIMEOUT_MS = 65000
4446

4547
client.interceptors.request.use((request) => {
4648
const token = getStoredToken()
@@ -254,6 +256,16 @@ export const api = {
254256
return postOnce<AiTestResponse>('/ai/test-vision', payload)
255257
},
256258

259+
async getDashboardAiSummary() {
260+
return unwrap<AiDashboardSummary>((await client.get('/ai/summary/dashboard')) as { data: Envelope<AiDashboardSummary> })
261+
},
262+
263+
async generateDashboardAiSummary() {
264+
return postOnce<AiDashboardSummary>('/ai/summary/dashboard/generate', undefined, {
265+
timeout: DASHBOARD_AI_SUMMARY_GENERATE_TIMEOUT_MS
266+
})
267+
},
268+
257269
async getTags() {
258270
return unwrap<Tag[]>((await client.get('/tags')) as { data: Envelope<Tag[]> })
259271
},
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
2+
import { useQuery } from '@tanstack/vue-query'
3+
import { api } from '@/composables/api'
4+
5+
export const DASHBOARD_AI_SUMMARY_QUERY_KEY = ['dashboard-ai-summary'] as const
6+
7+
export function useDashboardAiSummaryQuery(enabled: MaybeRefOrGetter<boolean> = true) {
8+
return useQuery({
9+
queryKey: DASHBOARD_AI_SUMMARY_QUERY_KEY,
10+
queryFn: api.getDashboardAiSummary,
11+
enabled: computed(() => toValue(enabled)),
12+
staleTime: 0,
13+
gcTime: 5 * 60_000,
14+
refetchOnWindowFocus: false
15+
})
16+
}

apps/web/src/pages/StatisticsPage.vue

Lines changed: 220 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,83 @@
77
icon-background="linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%)"
88
/>
99

10-
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12">
10+
<n-grid v-if="showAiSummaryCard" :cols="1" :x-gap="12" :y-gap="12">
11+
<n-grid-item>
12+
<n-card title="AI 总结">
13+
<template #header-extra>
14+
<div class="ai-summary-header-actions">
15+
<span v-if="dashboardAiSummary?.generatedAt" class="card-muted ai-summary-generated-at">
16+
最近生成:{{ summaryGeneratedAtText(dashboardAiSummary.generatedAt) }}
17+
</span>
18+
<n-button
19+
quaternary
20+
size="small"
21+
class="ai-summary-toggle"
22+
@click="summaryExpanded = !summaryExpanded"
23+
>
24+
{{ summaryExpanded ? '收起详情' : '查看详情' }}
25+
</n-button>
26+
<n-button size="small" :loading="generatingSummary" :disabled="generatingSummary" @click="regenerateSummary">
27+
重新生成总结
28+
</n-button>
29+
</div>
30+
</template>
31+
32+
<n-space vertical :size="12" style="width: 100%">
33+
<div v-if="summaryExpanded" class="card-muted">基于当前统计自动生成,不会修改订阅数据</div>
34+
35+
<div v-if="summaryLoadingVisible" class="ai-summary-loading">
36+
<n-spin size="small" />
37+
<div class="card-muted">正在基于当前统计生成 AI 总结,请稍候…</div>
38+
</div>
39+
40+
<template v-else-if="dashboardAiSummary">
41+
<n-alert
42+
v-if="dashboardAiSummary.status === 'unconfigured'"
43+
type="warning"
44+
:show-icon="false"
45+
>
46+
请先前往系统设置启用 AI 能力与 AI 总结,之后统计页面会自动生成总结。
47+
</n-alert>
48+
49+
<n-alert
50+
v-else-if="dashboardAiSummary.status === 'failed'"
51+
type="error"
52+
:show-icon="false"
53+
>
54+
{{ dashboardAiSummary.errorMessage || 'AI 总结生成失败,请稍后重试。' }}
55+
</n-alert>
56+
57+
<n-empty
58+
v-else-if="!dashboardAiSummary.content"
59+
description="暂无 AI 总结"
60+
/>
61+
62+
<template v-else>
63+
<div v-if="!summaryExpanded" class="ai-summary-preview">
64+
<div class="ai-summary-preview__label">摘要</div>
65+
<div class="ai-summary-preview__text">{{ dashboardSummaryPreviewText }}</div>
66+
</div>
67+
<n-collapse-transition :show="summaryExpanded">
68+
<div
69+
v-if="summaryExpanded"
70+
class="ai-summary-markdown"
71+
v-html="dashboardSummaryHtml"
72+
/>
73+
</n-collapse-transition>
74+
</template>
75+
</template>
76+
77+
<n-empty
78+
v-else-if="!dashboardAiSummaryQuery.isLoading.value"
79+
description="暂无 AI 总结"
80+
/>
81+
</n-space>
82+
</n-card>
83+
</n-grid-item>
84+
</n-grid>
85+
86+
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12" style="margin-top: 12px">
1187
<n-grid-item>
1288
<n-card title="月支付趋势(未来12个月)">
1389
<chart-view v-if="trendOption" :option="trendOption" />
@@ -60,32 +136,60 @@
60136
</n-card>
61137
</n-grid-item>
62138
</n-grid>
139+
63140
</div>
64141
</template>
65142

66143
<script setup lang="ts">
67-
import { computed } from 'vue'
144+
import { computed, ref, watch } from 'vue'
68145
import { useWindowSize } from '@vueuse/core'
69-
import { NCard, NEmpty, NGrid, NGridItem, useThemeVars } from 'naive-ui'
146+
import { useQueryClient } from '@tanstack/vue-query'
147+
import { NAlert, NButton, NCard, NCollapseTransition, NEmpty, NGrid, NGridItem, NSpace, NSpin, useMessage, useThemeVars } from 'naive-ui'
70148
import { BarChartOutline } from '@vicons/ionicons5'
149+
import { api } from '@/composables/api'
150+
import { DASHBOARD_AI_SUMMARY_QUERY_KEY, useDashboardAiSummaryQuery } from '@/composables/dashboard-ai-summary-query'
71151
import { useSettingsQuery } from '@/composables/settings-query'
72152
import { useStatisticsOverviewQuery } from '@/composables/statistics-overview-query'
73153
import ChartView from '@/components/ChartView.vue'
74154
import PageHeader from '@/components/PageHeader.vue'
75-
import type { StatisticsOverview, SubscriptionStatus } from '@/types/api'
76-
import { formatDateInTimezone } from '@/utils/timezone'
155+
import { formatAiSummaryPreviewText } from '@subtracker/shared'
156+
import type { SubscriptionStatus } from '@/types/api'
157+
import { renderMarkdownToHtml } from '@/utils/simple-markdown'
158+
import { formatDateInTimezone, formatDateTimeInTimezone } from '@/utils/timezone'
77159
import { buildTopSubscriptionsOption } from '@/utils/statistics-top-subscriptions'
78160
79161
const { width } = useWindowSize()
80162
const barChartOutline = BarChartOutline
81163
const themeVars = useThemeVars()
164+
const queryClient = useQueryClient()
165+
const message = useMessage()
82166
83167
const { data: overview } = useStatisticsOverviewQuery()
84-
85168
const { data: settings } = useSettingsQuery()
86169
170+
const showAiSummaryCard = computed(() => Boolean(settings.value?.aiConfig.enabled && settings.value?.aiConfig.dashboardSummaryEnabled))
171+
const dashboardAiSummaryQuery = useDashboardAiSummaryQuery(showAiSummaryCard)
172+
const dashboardAiSummary = computed(() => dashboardAiSummaryQuery.data.value)
173+
const generatingSummary = ref(false)
174+
const autoGenerateAttempted = ref(false)
175+
const summaryExpanded = ref(false)
176+
87177
const baseCurrency = computed(() => settings.value?.baseCurrency ?? 'CNY')
88178
const gridCols = computed(() => (width.value < 1100 ? 1 : 2))
179+
const summaryLoadingVisible = computed(
180+
() =>
181+
showAiSummaryCard.value && (
182+
generatingSummary.value ||
183+
dashboardAiSummary.value?.status === 'generating' ||
184+
(dashboardAiSummaryQuery.isLoading.value && !dashboardAiSummary.value?.content)
185+
)
186+
)
187+
const dashboardSummaryHtml = computed(() =>
188+
dashboardAiSummary.value?.content ? renderMarkdownToHtml(dashboardAiSummary.value.content) : ''
189+
)
190+
const dashboardSummaryPreviewText = computed(() =>
191+
formatAiSummaryPreviewText(dashboardAiSummary.value?.previewContent || dashboardAiSummary.value?.content || '')
192+
)
89193
90194
const statusLabelMap: Record<SubscriptionStatus, string> = {
91195
active: '正常',
@@ -324,4 +428,114 @@ const topSubscriptionsOption = computed(() =>
324428
function formatMoney(amount: number, currency: string) {
325429
return `${currency} ${amount.toFixed(2)}`
326430
}
431+
432+
async function regenerateSummary() {
433+
if (!showAiSummaryCard.value || generatingSummary.value) return
434+
generatingSummary.value = true
435+
try {
436+
await api.generateDashboardAiSummary()
437+
await queryClient.invalidateQueries({ queryKey: DASHBOARD_AI_SUMMARY_QUERY_KEY })
438+
message.success('AI 总结已更新')
439+
} catch (error) {
440+
message.error(error instanceof Error ? error.message : 'AI 总结生成失败')
441+
} finally {
442+
generatingSummary.value = false
443+
}
444+
}
445+
446+
function summaryGeneratedAtText(value: string) {
447+
return formatDateTimeInTimezone(value, settings.value?.timezone)
448+
}
449+
450+
watch(
451+
[() => dashboardAiSummaryQuery.isLoading.value, dashboardAiSummary, overview],
452+
async ([loading, summary, currentOverview]) => {
453+
if (!showAiSummaryCard.value || loading || generatingSummary.value || autoGenerateAttempted.value || !currentOverview) return
454+
if (!summary) {
455+
autoGenerateAttempted.value = true
456+
await regenerateSummary()
457+
return
458+
}
459+
if (!summary.canGenerate || !summary.needsGeneration) return
460+
461+
autoGenerateAttempted.value = true
462+
await regenerateSummary()
463+
},
464+
{ immediate: true }
465+
)
327466
</script>
467+
468+
<style scoped>
469+
.card-muted {
470+
color: var(--app-text-secondary);
471+
}
472+
473+
.ai-summary-header-actions {
474+
display: flex;
475+
align-items: center;
476+
gap: 8px;
477+
flex-wrap: wrap;
478+
justify-content: flex-end;
479+
}
480+
481+
.ai-summary-generated-at {
482+
white-space: nowrap;
483+
}
484+
485+
.ai-summary-toggle {
486+
font-weight: 600;
487+
}
488+
489+
.ai-summary-loading {
490+
min-height: 96px;
491+
display: flex;
492+
flex-direction: column;
493+
align-items: center;
494+
justify-content: center;
495+
gap: 10px;
496+
text-align: center;
497+
}
498+
499+
.ai-summary-preview {
500+
max-width: 100%;
501+
}
502+
503+
.ai-summary-preview__label {
504+
margin-bottom: 8px;
505+
font-size: 13px;
506+
font-weight: 600;
507+
color: var(--app-text-secondary);
508+
}
509+
510+
.ai-summary-preview__text {
511+
color: var(--app-text-primary);
512+
line-height: 1.85;
513+
word-break: break-word;
514+
white-space: normal;
515+
}
516+
517+
.ai-summary-markdown {
518+
color: var(--app-text-primary);
519+
line-height: 1.75;
520+
word-break: break-word;
521+
}
522+
523+
.ai-summary-markdown :deep(h2),
524+
.ai-summary-markdown :deep(h3) {
525+
margin: 0 0 10px;
526+
color: var(--app-text-strong);
527+
}
528+
529+
.ai-summary-markdown :deep(p) {
530+
margin: 0 0 10px;
531+
}
532+
533+
.ai-summary-markdown :deep(ul) {
534+
margin: 0 0 12px;
535+
padding-left: 20px;
536+
}
537+
538+
.ai-summary-markdown :deep(li) {
539+
margin-bottom: 6px;
540+
}
541+
</style>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import DOMPurify from 'dompurify'
2+
import { marked } from 'marked'
3+
4+
marked.setOptions({
5+
gfm: true,
6+
breaks: true
7+
})
8+
9+
export function renderMarkdownToHtml(markdown: string) {
10+
const normalized = String(markdown ?? '').replace(/\r\n/g, '\n').trim()
11+
if (!normalized) return ''
12+
13+
const rendered = marked.parse(normalized, { async: false })
14+
return DOMPurify.sanitize(rendered, {
15+
USE_PROFILES: { html: true }
16+
})
17+
}

0 commit comments

Comments
 (0)