|
7 | 7 | icon-background="linear-gradient(135deg, #0ea5e9 0%, #2563eb 100%)" |
8 | 8 | /> |
9 | 9 |
|
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"> |
11 | 87 | <n-grid-item> |
12 | 88 | <n-card title="月支付趋势(未来12个月)"> |
13 | 89 | <chart-view v-if="trendOption" :option="trendOption" /> |
|
60 | 136 | </n-card> |
61 | 137 | </n-grid-item> |
62 | 138 | </n-grid> |
| 139 | + |
63 | 140 | </div> |
64 | 141 | </template> |
65 | 142 |
|
66 | 143 | <script setup lang="ts"> |
67 | | -import { computed } from 'vue' |
| 144 | +import { computed, ref, watch } from 'vue' |
68 | 145 | 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' |
70 | 148 | 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' |
71 | 151 | import { useSettingsQuery } from '@/composables/settings-query' |
72 | 152 | import { useStatisticsOverviewQuery } from '@/composables/statistics-overview-query' |
73 | 153 | import ChartView from '@/components/ChartView.vue' |
74 | 154 | 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' |
77 | 159 | import { buildTopSubscriptionsOption } from '@/utils/statistics-top-subscriptions' |
78 | 160 |
|
79 | 161 | const { width } = useWindowSize() |
80 | 162 | const barChartOutline = BarChartOutline |
81 | 163 | const themeVars = useThemeVars() |
| 164 | +const queryClient = useQueryClient() |
| 165 | +const message = useMessage() |
82 | 166 |
|
83 | 167 | const { data: overview } = useStatisticsOverviewQuery() |
84 | | -
|
85 | 168 | const { data: settings } = useSettingsQuery() |
86 | 169 |
|
| 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 | +
|
87 | 177 | const baseCurrency = computed(() => settings.value?.baseCurrency ?? 'CNY') |
88 | 178 | 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 | +) |
89 | 193 |
|
90 | 194 | const statusLabelMap: Record<SubscriptionStatus, string> = { |
91 | 195 | active: '正常', |
@@ -324,4 +428,114 @@ const topSubscriptionsOption = computed(() => |
324 | 428 | function formatMoney(amount: number, currency: string) { |
325 | 429 | return `${currency} ${amount.toFixed(2)}` |
326 | 430 | } |
| 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 | +) |
327 | 466 | </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> |
0 commit comments