mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-06-05 02:26:25 +08:00
perf: reduce main page request fanout
Reuse shared page queries across main and reduce repeated page-load requests.
This commit is contained in:
14
apps/web/src/composables/budget-statistics-query.ts
Normal file
14
apps/web/src/composables/budget-statistics-query.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { api } from '@/composables/api'
|
||||
|
||||
export const BUDGET_STATISTICS_QUERY_KEY = ['statistics-budgets'] as const
|
||||
|
||||
export function useBudgetStatisticsQuery() {
|
||||
return useQuery({
|
||||
queryKey: BUDGET_STATISTICS_QUERY_KEY,
|
||||
queryFn: api.getBudgetStatistics,
|
||||
staleTime: 5_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false
|
||||
})
|
||||
}
|
||||
20
apps/web/src/composables/calendar-events-query.ts
Normal file
20
apps/web/src/composables/calendar-events-query.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { api } from '@/composables/api'
|
||||
|
||||
type CalendarRange = {
|
||||
start?: string
|
||||
end?: string
|
||||
}
|
||||
|
||||
export function useCalendarEventsQuery(params: MaybeRefOrGetter<CalendarRange>) {
|
||||
const normalizedParams = computed(() => toValue(params))
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => ['calendar-events', normalizedParams.value]),
|
||||
queryFn: () => api.getCalendarEvents(normalizedParams.value),
|
||||
staleTime: 5_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false
|
||||
})
|
||||
}
|
||||
14
apps/web/src/composables/exchange-rate-query.ts
Normal file
14
apps/web/src/composables/exchange-rate-query.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { api } from '@/composables/api'
|
||||
|
||||
export const EXCHANGE_RATE_SNAPSHOT_QUERY_KEY = ['exchange-rate-snapshot'] as const
|
||||
|
||||
export function useExchangeRateSnapshotQuery() {
|
||||
return useQuery({
|
||||
queryKey: EXCHANGE_RATE_SNAPSHOT_QUERY_KEY,
|
||||
queryFn: api.getExchangeRateSnapshot,
|
||||
staleTime: 5_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false
|
||||
})
|
||||
}
|
||||
14
apps/web/src/composables/notification-webhook-query.ts
Normal file
14
apps/web/src/composables/notification-webhook-query.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { api } from '@/composables/api'
|
||||
|
||||
export const NOTIFICATION_WEBHOOK_QUERY_KEY = ['notification-webhook'] as const
|
||||
|
||||
export function useNotificationWebhookQuery() {
|
||||
return useQuery({
|
||||
queryKey: NOTIFICATION_WEBHOOK_QUERY_KEY,
|
||||
queryFn: api.getNotificationWebhook,
|
||||
staleTime: 5_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false
|
||||
})
|
||||
}
|
||||
14
apps/web/src/composables/statistics-overview-query.ts
Normal file
14
apps/web/src/composables/statistics-overview-query.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { api } from '@/composables/api'
|
||||
|
||||
export const STATISTICS_OVERVIEW_QUERY_KEY = ['statistics-overview'] as const
|
||||
|
||||
export function useStatisticsOverviewQuery() {
|
||||
return useQuery({
|
||||
queryKey: STATISTICS_OVERVIEW_QUERY_KEY,
|
||||
queryFn: api.getStatisticsOverview,
|
||||
staleTime: 5_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false
|
||||
})
|
||||
}
|
||||
14
apps/web/src/composables/tags-query.ts
Normal file
14
apps/web/src/composables/tags-query.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { api } from '@/composables/api'
|
||||
|
||||
export const TAGS_QUERY_KEY = ['tags'] as const
|
||||
|
||||
export function useTagsQuery() {
|
||||
return useQuery({
|
||||
queryKey: TAGS_QUERY_KEY,
|
||||
queryFn: api.getTags,
|
||||
staleTime: 5_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false
|
||||
})
|
||||
}
|
||||
@@ -148,13 +148,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NCard, NDataTable, NDivider, NEmpty, NGrid, NGridItem, NProgress, NSpace, NTag, useMessage } from 'naive-ui'
|
||||
import { WalletOutline } from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import { BUDGET_STATISTICS_QUERY_KEY, useBudgetStatisticsQuery } from '@/composables/budget-statistics-query'
|
||||
import { SETTINGS_QUERY_KEY, useSettingsQuery } from '@/composables/settings-query'
|
||||
import { TAGS_QUERY_KEY, useTagsQuery } from '@/composables/tags-query'
|
||||
import ChartView from '@/components/ChartView.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import TagBudgetSettingsModal from '@/components/TagBudgetSettingsModal.vue'
|
||||
@@ -167,18 +169,12 @@ const message = useMessage()
|
||||
const queryClient = useQueryClient()
|
||||
const tagBudgetModalVisible = ref(false)
|
||||
|
||||
const { data: budgetStats } = useQuery({
|
||||
queryKey: ['statistics-budgets'],
|
||||
queryFn: api.getBudgetStatistics
|
||||
})
|
||||
const { data: budgetStats } = useBudgetStatisticsQuery()
|
||||
|
||||
const { data: settings } = useSettingsQuery()
|
||||
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: ['budget-page-tags'],
|
||||
queryFn: api.getTags,
|
||||
initialData: []
|
||||
})
|
||||
const { data: tagsData } = useTagsQuery()
|
||||
const tags = computed(() => tagsData.value ?? [])
|
||||
|
||||
watch(
|
||||
() => settings.value?.enableTagBudgets,
|
||||
@@ -292,11 +288,12 @@ async function saveTagBudgets(tagBudgets: Record<string, number>) {
|
||||
await api.updateSettings({ tagBudgets })
|
||||
tagBudgetModalVisible.value = false
|
||||
message.success('标签月预算已保存')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['statistics-budgets'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['statistics-overview'] }),
|
||||
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY })
|
||||
])
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: BUDGET_STATISTICS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: ['statistics-overview'] }),
|
||||
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||||
])
|
||||
}
|
||||
|
||||
function formatMoney(amount: number, currency: string) {
|
||||
|
||||
@@ -101,7 +101,7 @@ import {
|
||||
TodayOutline,
|
||||
WalletOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import { useCalendarEventsQuery } from '@/composables/calendar-events-query'
|
||||
import { useSettingsQuery } from '@/composables/settings-query'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
@@ -119,20 +119,25 @@ const events = ref<CalendarEvent[]>([])
|
||||
const tab = ref('month')
|
||||
const selectedDateTs = ref(dayjs().valueOf())
|
||||
const panelMonthTs = ref(dayjs().startOf('month').valueOf())
|
||||
let latestMonthRequestId = 0
|
||||
let ignoreSelectedDateWatch = false
|
||||
const monthEventsCache = new Map<string, CalendarEvent[]>()
|
||||
const { data: settings } = useSettingsQuery()
|
||||
const baseCurrency = computed(() => settings.value?.baseCurrency ?? 'CNY')
|
||||
const panelMonthRange = computed(() => {
|
||||
const monthStart = dayjs(panelMonthTs.value).startOf('month')
|
||||
return {
|
||||
start: monthStart.format('YYYY-MM-DD'),
|
||||
end: monthStart.endOf('month').format('YYYY-MM-DD')
|
||||
}
|
||||
})
|
||||
const calendarEventsQuery = useCalendarEventsQuery(panelMonthRange)
|
||||
|
||||
const summaryCols = computed(() => (width.value < 640 ? 1 : width.value < 1100 ? 2 : 4))
|
||||
const calendarCols = computed(() => (width.value < 1100 ? 1 : 2))
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
if (width.value < 720) {
|
||||
tab.value = 'list'
|
||||
}
|
||||
await loadEventsForMonth(panelMonthTs.value)
|
||||
})
|
||||
|
||||
watch(selectedDateTs, async (value) => {
|
||||
@@ -143,56 +148,17 @@ watch(selectedDateTs, async (value) => {
|
||||
|
||||
const selectedMonth = dayjs(value).startOf('month')
|
||||
if (!selectedMonth.isSame(dayjs(panelMonthTs.value), 'month')) {
|
||||
await loadEventsForMonth(value)
|
||||
panelMonthTs.value = selectedMonth.valueOf()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadEventsForMonth(monthTs: number) {
|
||||
const requestId = ++latestMonthRequestId
|
||||
const monthStart = dayjs(monthTs).startOf('month')
|
||||
const cacheKey = monthStart.format('YYYY-MM')
|
||||
panelMonthTs.value = monthStart.valueOf()
|
||||
|
||||
const cached = monthEventsCache.get(cacheKey)
|
||||
if (cached) {
|
||||
events.value = cached
|
||||
void prefetchAdjacentMonths(monthStart.valueOf())
|
||||
return
|
||||
}
|
||||
|
||||
events.value = []
|
||||
const start = monthStart.startOf('month').format('YYYY-MM-DD')
|
||||
const end = monthStart.endOf('month').format('YYYY-MM-DD')
|
||||
const rows = await api.getCalendarEvents({ start, end })
|
||||
|
||||
if (requestId !== latestMonthRequestId) return
|
||||
|
||||
monthEventsCache.set(cacheKey, rows)
|
||||
events.value = rows
|
||||
void prefetchAdjacentMonths(monthStart.valueOf())
|
||||
}
|
||||
|
||||
async function fetchMonthEvents(monthTs: number) {
|
||||
const monthStart = dayjs(monthTs).startOf('month')
|
||||
const cacheKey = monthStart.format('YYYY-MM')
|
||||
const cached = monthEventsCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const rows = await api.getCalendarEvents({
|
||||
start: monthStart.startOf('month').format('YYYY-MM-DD'),
|
||||
end: monthStart.endOf('month').format('YYYY-MM-DD')
|
||||
})
|
||||
monthEventsCache.set(cacheKey, rows)
|
||||
return rows
|
||||
}
|
||||
|
||||
async function prefetchAdjacentMonths(monthTs: number) {
|
||||
const currentMonth = dayjs(monthTs).startOf('month')
|
||||
await Promise.allSettled([
|
||||
fetchMonthEvents(currentMonth.add(1, 'month').valueOf()),
|
||||
fetchMonthEvents(currentMonth.subtract(1, 'month').valueOf())
|
||||
])
|
||||
}
|
||||
watch(
|
||||
() => calendarEventsQuery.data.value,
|
||||
(value) => {
|
||||
events.value = value ?? []
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const panelMonthLabel = computed(() => dayjs(panelMonthTs.value).format('YYYY 年 M 月'))
|
||||
const selectedDateLabel = computed(() => dayjs(selectedDateTs.value).format('YYYY-MM-DD'))
|
||||
@@ -246,7 +212,7 @@ function handlePanelChange({ year, month }: { year: number; month: number }) {
|
||||
|
||||
ignoreSelectedDateWatch = true
|
||||
selectedDateTs.value = targetSelectedDate.valueOf()
|
||||
void loadEventsForMonth(targetMonth.valueOf())
|
||||
panelMonthTs.value = targetMonth.valueOf()
|
||||
}
|
||||
|
||||
function getDaySummary(year: number, month: number, date: number) {
|
||||
|
||||
@@ -117,12 +117,11 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, h } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { NCard, NDataTable, NEmpty, NGrid, NGridItem, NProgress, NTag } from 'naive-ui'
|
||||
import { CashOutline, GridOutline, LayersOutline, NotificationsOutline, WalletOutline } from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import { useSettingsQuery } from '@/composables/settings-query'
|
||||
import { useStatisticsOverviewQuery } from '@/composables/statistics-overview-query'
|
||||
import ChartView from '@/components/ChartView.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
@@ -132,10 +131,7 @@ import { getSubscriptionStatusTagType, getSubscriptionStatusText } from '@/utils
|
||||
const { width } = useWindowSize()
|
||||
const gridOutline = GridOutline
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ['statistics-overview'],
|
||||
queryFn: api.getStatisticsOverview
|
||||
})
|
||||
const { data: overview } = useStatisticsOverviewQuery()
|
||||
|
||||
const { data: settings } = useSettingsQuery()
|
||||
|
||||
|
||||
@@ -440,7 +440,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import {
|
||||
@@ -475,6 +475,8 @@ import {
|
||||
} from 'naive-ui'
|
||||
import { HelpCircleOutline, RefreshOutline, SaveOutline, SettingsOutline } from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import { EXCHANGE_RATE_SNAPSHOT_QUERY_KEY, useExchangeRateSnapshotQuery } from '@/composables/exchange-rate-query'
|
||||
import { NOTIFICATION_WEBHOOK_QUERY_KEY, useNotificationWebhookQuery } from '@/composables/notification-webhook-query'
|
||||
import { SETTINGS_QUERY_KEY, useSettingsQuery } from '@/composables/settings-query'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import WallosImportModal from '@/components/WallosImportModal.vue'
|
||||
@@ -488,6 +490,8 @@ const message = useMessage()
|
||||
const authStore = useAuthStore()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: settingsQueryData } = useSettingsQuery()
|
||||
const { data: snapshotQueryData } = useExchangeRateSnapshotQuery()
|
||||
const { data: webhookQueryData } = useNotificationWebhookQuery()
|
||||
const { width } = useWindowSize()
|
||||
const helpCircleOutline = HelpCircleOutline
|
||||
const settingsOutline = SettingsOutline
|
||||
@@ -695,10 +699,6 @@ function validateAiSettings(action: 'save' | 'connection-test' | 'vision-test')
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadSnapshot(), loadWebhook()])
|
||||
})
|
||||
|
||||
watch(
|
||||
settingsQueryData,
|
||||
(settings) => {
|
||||
@@ -712,13 +712,26 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function loadSnapshot() {
|
||||
snapshot.value = await api.getExchangeRateSnapshot()
|
||||
}
|
||||
watch(
|
||||
snapshotQueryData,
|
||||
(value) => {
|
||||
snapshot.value = value ?? null
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function loadWebhook() {
|
||||
const current = await api.getNotificationWebhook()
|
||||
Object.assign(webhookForm, current)
|
||||
watch(
|
||||
webhookQueryData,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
Object.assign(webhookForm, value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function applySavedSettings(result: Settings) {
|
||||
Object.assign(settingsForm, cloneSettingsForForm(result))
|
||||
queryClient.setQueryData(SETTINGS_QUERY_KEY, result)
|
||||
}
|
||||
|
||||
async function saveBasicSettings() {
|
||||
@@ -736,7 +749,7 @@ async function saveBasicSettings() {
|
||||
defaultOverdueReminderRules: settingsForm.defaultOverdueReminderRules,
|
||||
tagBudgets: settingsForm.tagBudgets
|
||||
})
|
||||
Object.assign(settingsForm, result)
|
||||
applySavedSettings(result)
|
||||
message.success('基础设置已保存')
|
||||
targetCurrency.value = settingsForm.baseCurrency.toUpperCase()
|
||||
await Promise.all([
|
||||
@@ -744,7 +757,7 @@ async function saveBasicSettings() {
|
||||
queryClient.invalidateQueries({ queryKey: ['statistics-overview'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['statistics-budgets'] })
|
||||
])
|
||||
await loadSnapshot()
|
||||
await queryClient.invalidateQueries({ queryKey: EXCHANGE_RATE_SNAPSHOT_QUERY_KEY })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '基础设置保存失败')
|
||||
} finally {
|
||||
@@ -757,10 +770,11 @@ async function saveEmailSettings() {
|
||||
if (!validateEmailSettings('save')) return
|
||||
savingEmailSettings.value = true
|
||||
try {
|
||||
await api.updateSettings({
|
||||
const result = await api.updateSettings({
|
||||
emailNotificationsEnabled: settingsForm.emailNotificationsEnabled,
|
||||
emailConfig: settingsForm.emailConfig
|
||||
})
|
||||
applySavedSettings(result)
|
||||
message.success(settingsForm.emailNotificationsEnabled ? '邮箱通知配置已保存' : '邮箱通知已关闭')
|
||||
} finally {
|
||||
savingEmailSettings.value = false
|
||||
@@ -772,10 +786,11 @@ async function savePushplusSettings() {
|
||||
if (!validatePushplusSettings('save')) return
|
||||
savingPushplusSettings.value = true
|
||||
try {
|
||||
await api.updateSettings({
|
||||
const result = await api.updateSettings({
|
||||
pushplusNotificationsEnabled: settingsForm.pushplusNotificationsEnabled,
|
||||
pushplusConfig: settingsForm.pushplusConfig
|
||||
})
|
||||
applySavedSettings(result)
|
||||
message.success(settingsForm.pushplusNotificationsEnabled ? 'PushPlus 配置已保存' : 'PushPlus 已关闭')
|
||||
} finally {
|
||||
savingPushplusSettings.value = false
|
||||
@@ -787,10 +802,11 @@ async function saveTelegramSettings() {
|
||||
if (!validateTelegramSettings('save')) return
|
||||
savingTelegramSettings.value = true
|
||||
try {
|
||||
await api.updateSettings({
|
||||
const result = await api.updateSettings({
|
||||
telegramNotificationsEnabled: settingsForm.telegramNotificationsEnabled,
|
||||
telegramConfig: settingsForm.telegramConfig
|
||||
})
|
||||
applySavedSettings(result)
|
||||
message.success(settingsForm.telegramNotificationsEnabled ? 'Telegram 配置已保存' : 'Telegram 已关闭')
|
||||
} finally {
|
||||
savingTelegramSettings.value = false
|
||||
@@ -805,7 +821,7 @@ async function saveAiSettings() {
|
||||
aiPromptInput.value = promptTemplate || DEFAULT_AI_SUBSCRIPTION_PROMPT
|
||||
savingAiSettings.value = true
|
||||
try {
|
||||
await api.updateSettings({
|
||||
const result = await api.updateSettings({
|
||||
aiConfig: {
|
||||
...settingsForm.aiConfig,
|
||||
capabilities: {
|
||||
@@ -814,6 +830,8 @@ async function saveAiSettings() {
|
||||
promptTemplate
|
||||
}
|
||||
})
|
||||
applySavedSettings(result)
|
||||
aiPromptInput.value = result.aiConfig.promptTemplate.trim() || DEFAULT_AI_SUBSCRIPTION_PROMPT
|
||||
message.success(settingsForm.aiConfig.enabled ? 'AI 识别配置已保存' : 'AI 识别已关闭')
|
||||
} finally {
|
||||
savingAiSettings.value = false
|
||||
@@ -878,6 +896,7 @@ function handleAiPresetChange(value: AiProviderPreset) {
|
||||
|
||||
async function refreshRates() {
|
||||
snapshot.value = await api.refreshExchangeRates()
|
||||
queryClient.setQueryData(EXCHANGE_RATE_SNAPSHOT_QUERY_KEY, snapshot.value)
|
||||
message.success('汇率已刷新')
|
||||
}
|
||||
|
||||
@@ -965,6 +984,7 @@ async function saveWebhook() {
|
||||
ignoreSsl: webhookForm.ignoreSsl
|
||||
})
|
||||
Object.assign(webhookForm, saved)
|
||||
queryClient.setQueryData(NOTIFICATION_WEBHOOK_QUERY_KEY, saved)
|
||||
message.success(webhookForm.enabled ? 'Webhook 配置已保存' : 'Webhook 已关闭')
|
||||
} finally {
|
||||
savingWebhookSettings.value = false
|
||||
|
||||
@@ -66,12 +66,11 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { NCard, NEmpty, NGrid, NGridItem } from 'naive-ui'
|
||||
import { BarChartOutline } from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import { useSettingsQuery } from '@/composables/settings-query'
|
||||
import { useStatisticsOverviewQuery } from '@/composables/statistics-overview-query'
|
||||
import ChartView from '@/components/ChartView.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import type { StatisticsOverview, SubscriptionStatus } from '@/types/api'
|
||||
@@ -80,10 +79,7 @@ import { buildTopSubscriptionsOption } from '@/utils/statistics-top-subscription
|
||||
const { width } = useWindowSize()
|
||||
const barChartOutline = BarChartOutline
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ['statistics-overview'],
|
||||
queryFn: api.getStatisticsOverview
|
||||
})
|
||||
const { data: overview } = useStatisticsOverviewQuery()
|
||||
|
||||
const { data: settings } = useSettingsQuery()
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, h, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
@@ -274,7 +275,9 @@ import {
|
||||
SearchOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { api } from '@/composables/api'
|
||||
import { useExchangeRateSnapshotQuery } from '@/composables/exchange-rate-query'
|
||||
import { useSettingsQuery } from '@/composables/settings-query'
|
||||
import { TAGS_QUERY_KEY, useTagsQuery } from '@/composables/tags-query'
|
||||
import TagManageModal from '@/components/TagManageModal.vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import SubscriptionDetailDrawer from '@/components/SubscriptionDetailDrawer.vue'
|
||||
@@ -295,12 +298,15 @@ type SortMode = 'custom' | 'renewal' | 'amount-desc' | 'name'
|
||||
|
||||
const message = useMessage()
|
||||
const { width } = useWindowSize()
|
||||
const queryClient = useQueryClient()
|
||||
const layersOutline = LayersOutline
|
||||
const isMobile = computed(() => width.value < 960)
|
||||
|
||||
const subscriptions = ref<Subscription[]>([])
|
||||
const tags = ref<Tag[]>([])
|
||||
const { data: settings } = useSettingsQuery()
|
||||
const { data: tagsQueryData } = useTagsQuery()
|
||||
const { data: snapshotQueryData } = useExchangeRateSnapshotQuery()
|
||||
const detail = ref<SubscriptionDetail | null>(null)
|
||||
const paymentRecords = ref<PaymentRecord[]>([])
|
||||
const currencies = ref<string[]>(['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'HKD'])
|
||||
@@ -312,6 +318,11 @@ const filters = reactive({
|
||||
status: null as string | null,
|
||||
tagIds: [] as string[]
|
||||
})
|
||||
const appliedFilters = reactive({
|
||||
q: '',
|
||||
status: null as string | null,
|
||||
tagIds: [] as string[]
|
||||
})
|
||||
|
||||
const sortMode = ref<SortMode>('custom')
|
||||
const showModal = ref(false)
|
||||
@@ -380,6 +391,18 @@ const canBatchCancel = computed(
|
||||
const canBatchDelete = computed(
|
||||
() => selectedCount.value > 0 && selectedSubscriptions.value.every((item) => item.status !== 'active')
|
||||
)
|
||||
const subscriptionQueryParams = computed(() => ({
|
||||
q: appliedFilters.q || undefined,
|
||||
status: appliedFilters.status || undefined,
|
||||
tagIds: appliedFilters.tagIds.length ? appliedFilters.tagIds.join(',') : undefined
|
||||
}))
|
||||
const subscriptionsQuery = useQuery({
|
||||
queryKey: computed(() => ['subscriptions', subscriptionQueryParams.value]),
|
||||
queryFn: () => api.getSubscriptions(subscriptionQueryParams.value),
|
||||
staleTime: 5_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false
|
||||
})
|
||||
|
||||
const orderedSubscriptions = computed(() => {
|
||||
const rows = [...subscriptions.value]
|
||||
@@ -643,7 +666,6 @@ const tableRows = computed<SubscriptionTableRow[]>(() => buildSubscriptionTableR
|
||||
onMounted(async () => {
|
||||
desktopPageSize.value = getStoredSubscriptionPageSize()
|
||||
window.addEventListener('mouseup', resetArmedDrag)
|
||||
await Promise.all([loadTags(), loadSubscriptions(), loadCurrencies()])
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -668,26 +690,51 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
async function loadTags() {
|
||||
tags.value = await api.getTags()
|
||||
}
|
||||
|
||||
async function loadSubscriptions() {
|
||||
resetDragState()
|
||||
currentPage.value = 1
|
||||
subscriptions.value = await api.getSubscriptions({
|
||||
q: filters.q || undefined,
|
||||
status: filters.status || undefined,
|
||||
tagIds: filters.tagIds.length ? filters.tagIds.join(',') : undefined
|
||||
})
|
||||
const existingIds = new Set(subscriptions.value.map((item) => item.id))
|
||||
selectedSubscriptionIds.value = selectedSubscriptionIds.value.filter((id) => existingIds.has(id))
|
||||
const nextTagIds = [...filters.tagIds]
|
||||
const filtersChanged =
|
||||
appliedFilters.q !== filters.q ||
|
||||
appliedFilters.status !== filters.status ||
|
||||
appliedFilters.tagIds.length !== nextTagIds.length ||
|
||||
appliedFilters.tagIds.some((id, index) => id !== nextTagIds[index])
|
||||
|
||||
appliedFilters.q = filters.q
|
||||
appliedFilters.status = filters.status
|
||||
appliedFilters.tagIds = nextTagIds
|
||||
|
||||
if (!filtersChanged) {
|
||||
await subscriptionsQuery.refetch()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrencies() {
|
||||
const snapshot = await api.getExchangeRateSnapshot()
|
||||
currencies.value = Array.from(new Set([snapshot.baseCurrency, ...Object.keys(snapshot.rates)])).sort()
|
||||
}
|
||||
watch(
|
||||
() => subscriptionsQuery.data.value,
|
||||
(value) => {
|
||||
subscriptions.value = value ?? []
|
||||
const existingIds = new Set(subscriptions.value.map((item) => item.id))
|
||||
selectedSubscriptionIds.value = selectedSubscriptionIds.value.filter((id) => existingIds.has(id))
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
tagsQueryData,
|
||||
(value) => {
|
||||
tags.value = value ?? []
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
snapshotQueryData,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
currencies.value = Array.from(new Set([value.baseCurrency, ...Object.keys(value.rates)])).sort()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
settings,
|
||||
@@ -699,6 +746,11 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function refetchCurrentSubscriptions() {
|
||||
await queryClient.invalidateQueries({ queryKey: ['subscriptions'] })
|
||||
await subscriptionsQuery.refetch()
|
||||
}
|
||||
|
||||
function toggleTagFilter(tagId: string) {
|
||||
if (filters.tagIds.includes(tagId)) {
|
||||
filters.tagIds = filters.tagIds.filter((item) => item !== tagId)
|
||||
@@ -757,7 +809,7 @@ const submitSubscriptionTask = createSingleFlight(async (payload: Record<string,
|
||||
}
|
||||
|
||||
closeModal()
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
} catch (error) {
|
||||
message.error(`保存失败:${error instanceof Error ? error.message : 'Unknown'}`)
|
||||
} finally {
|
||||
@@ -773,7 +825,7 @@ async function createTag(payload: { name: string; color: string; icon: string; s
|
||||
try {
|
||||
await api.createTag(payload)
|
||||
message.success('标签已创建')
|
||||
await Promise.all([loadTags(), loadSubscriptions()])
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }), refetchCurrentSubscriptions()])
|
||||
} catch (error) {
|
||||
message.error(`标签创建失败:${error instanceof Error ? error.message : 'Unknown'}`)
|
||||
}
|
||||
@@ -783,7 +835,7 @@ async function updateTag(payload: { name: string; color: string; icon: string; s
|
||||
try {
|
||||
await api.updateTag(id, payload)
|
||||
message.success('标签已更新')
|
||||
await Promise.all([loadTags(), loadSubscriptions()])
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }), refetchCurrentSubscriptions()])
|
||||
} catch (error) {
|
||||
message.error(`标签更新失败:${error instanceof Error ? error.message : 'Unknown'}`)
|
||||
}
|
||||
@@ -794,7 +846,7 @@ async function deleteTag(tag: Tag) {
|
||||
await api.deleteTag(tag.id)
|
||||
message.success(`已删除标签:${tag.name}`)
|
||||
filters.tagIds = filters.tagIds.filter((item) => item !== tag.id)
|
||||
await Promise.all([loadTags(), loadSubscriptions()])
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }), refetchCurrentSubscriptions()])
|
||||
} catch (error) {
|
||||
message.error(`标签删除失败:${error instanceof Error ? error.message : 'Unknown'}`)
|
||||
}
|
||||
@@ -850,7 +902,7 @@ async function runBatchRenew() {
|
||||
const ids = [...selectedSubscriptionIds.value]
|
||||
const result = await api.batchRenewSubscriptions(ids)
|
||||
summarizeBatchResult('批量续订', result)
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
await refreshOpenDetailIfNeeded(ids)
|
||||
}
|
||||
|
||||
@@ -860,7 +912,7 @@ async function runBatchPause() {
|
||||
const ids = [...selectedSubscriptionIds.value]
|
||||
const result = await api.batchPauseSubscriptions(ids)
|
||||
summarizeBatchResult('批量暂停', result)
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
await refreshOpenDetailIfNeeded(ids)
|
||||
}
|
||||
|
||||
@@ -870,7 +922,7 @@ async function runBatchCancel() {
|
||||
const ids = [...selectedSubscriptionIds.value]
|
||||
const result = await api.batchCancelSubscriptions(ids)
|
||||
summarizeBatchResult('批量取消', result)
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
await refreshOpenDetailIfNeeded(ids)
|
||||
}
|
||||
|
||||
@@ -884,13 +936,13 @@ async function runBatchDelete() {
|
||||
showDetailDrawer.value = false
|
||||
}
|
||||
clearSelectedSubscriptions()
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
}
|
||||
|
||||
async function quickRenew(row: Subscription) {
|
||||
await api.renewSubscription(row.id)
|
||||
message.success(`已续订:${row.name}`)
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
if (detail.value?.id === row.id) {
|
||||
detail.value = await api.getSubscription(row.id)
|
||||
}
|
||||
@@ -899,7 +951,7 @@ async function quickRenew(row: Subscription) {
|
||||
async function pause(id: string) {
|
||||
await api.pauseSubscription(id)
|
||||
message.success('已暂停')
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
if (detail.value?.id === id) {
|
||||
detail.value = await api.getSubscription(id)
|
||||
}
|
||||
@@ -908,7 +960,7 @@ async function pause(id: string) {
|
||||
async function cancel(id: string) {
|
||||
await api.cancelSubscription(id)
|
||||
message.success('已停用')
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
if (detail.value?.id === id) {
|
||||
detail.value = await api.getSubscription(id)
|
||||
}
|
||||
@@ -917,7 +969,7 @@ async function cancel(id: string) {
|
||||
async function removeSubscription(id: string, name: string) {
|
||||
await api.deleteSubscription(id)
|
||||
message.success(`已删除:${name}`)
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
if (detail.value?.id === id) {
|
||||
detail.value = null
|
||||
showDetailDrawer.value = false
|
||||
@@ -985,7 +1037,7 @@ async function handleDrop(event: DragEvent, targetId: string) {
|
||||
try {
|
||||
savingOrder.value = true
|
||||
await api.reorderSubscriptions(nextIds)
|
||||
await loadSubscriptions()
|
||||
await refetchCurrentSubscriptions()
|
||||
message.success('顺序已更新')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '排序更新失败')
|
||||
|
||||
Reference in New Issue
Block a user