feat:新增离线存储
This commit is contained in:
@@ -16,6 +16,12 @@ const usageVariant = computed(() => {
|
||||
if (usagePercent.value >= thresholdPercent.value - 10) return 'text-amber-500';
|
||||
return 'text-emerald-500';
|
||||
});
|
||||
|
||||
const thresholdLabel = computed(() => {
|
||||
if (usagePercent.value >= thresholdPercent.value) return '已超过阈值,注意控制支出';
|
||||
if (usagePercent.value >= thresholdPercent.value - 10) return '接近阈值,建议关注该分类';
|
||||
return '消耗平衡';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,8 +50,11 @@ const usageVariant = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm" :class="usageVariant">
|
||||
{{ usagePercent.toFixed(0) }}% 已使用
|
||||
</p>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<p :class="usageVariant">
|
||||
{{ usagePercent.toFixed(0) }}% 已使用
|
||||
</p>
|
||||
<p :class="usageVariant">{{ thresholdLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
55
apps/frontend/src/composables/useNotificationChannels.ts
Normal file
55
apps/frontend/src/composables/useNotificationChannels.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import { apiClient } from '../lib/api/client';
|
||||
import type { NotificationChannel } from '../types/notification';
|
||||
|
||||
const queryKey = ['notification-channels'];
|
||||
|
||||
export function useNotificationChannelsQuery() {
|
||||
return useQuery<NotificationChannel[]>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/notifications/channels');
|
||||
return data.data as NotificationChannel[];
|
||||
},
|
||||
initialData: []
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateChannelMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (payload: { packageName: string; displayName: string }) => {
|
||||
const { data } = await apiClient.post('/notifications/channels', payload);
|
||||
return data.data as NotificationChannel;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateChannelMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, payload }: { id: string; payload: Partial<NotificationChannel> }) => {
|
||||
const { data } = await apiClient.patch(`/notifications/channels/${id}`, payload);
|
||||
return data.data as NotificationChannel;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteChannelMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/notifications/channels/${id}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||
import { apiClient } from '../lib/api/client';
|
||||
import { persistTransactionsOffline, readTransactionsOffline, queuePendingTransaction, syncPendingTransactions } from '../lib/storage/offline-db';
|
||||
import type { Transaction, TransactionPayload } from '../types/transaction';
|
||||
|
||||
const queryKey = ['transactions'];
|
||||
@@ -27,8 +28,18 @@ export function useTransactionsQuery() {
|
||||
return useQuery<Transaction[]>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/transactions');
|
||||
return (data.data as Transaction[]).map(mapTransaction);
|
||||
try {
|
||||
const { data } = await apiClient.get('/transactions');
|
||||
const mapped = (data.data as Transaction[]).map(mapTransaction);
|
||||
await persistTransactionsOffline(mapped);
|
||||
await syncPendingTransactions(async (payload) => {
|
||||
await apiClient.post('/transactions', payload);
|
||||
});
|
||||
return mapped;
|
||||
} catch (error) {
|
||||
console.warn('[transactions] falling back to offline data', error);
|
||||
return readTransactionsOffline();
|
||||
}
|
||||
},
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 60_000
|
||||
@@ -46,7 +57,25 @@ export function useCreateTransactionMutation() {
|
||||
onSuccess: (transaction) => {
|
||||
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => [transaction, ...existing]);
|
||||
},
|
||||
onError: () => {
|
||||
onError: async (_error, payload) => {
|
||||
await queuePendingTransaction(payload);
|
||||
const optimistic: Transaction = {
|
||||
id: `offline-${Date.now()}`,
|
||||
title: payload.title,
|
||||
description: payload.title,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency ?? 'CNY',
|
||||
category: payload.category,
|
||||
type: payload.type,
|
||||
source: payload.source ?? 'manual',
|
||||
occurredAt: payload.occurredAt,
|
||||
status: payload.status ?? 'pending',
|
||||
notes: payload.notes,
|
||||
userId: undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => [optimistic, ...existing]);
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,6 +61,12 @@ const budgetFooter = computed(() => {
|
||||
|
||||
const navigateToAnalysis = () => router.push('/analysis');
|
||||
const navigateToBudgets = () => router.push('/settings');
|
||||
|
||||
const budgetAlerts = computed(() =>
|
||||
budgets.value.filter(
|
||||
(budget) => budget.amount > 0 && (budget.usage / budget.amount >= budget.threshold || budget.usage >= budget.amount)
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -112,6 +118,19 @@ const navigateToBudgets = () => router.push('/settings');
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="budgetAlerts.length" class="bg-amber-50 border border-amber-200 rounded-2xl p-4 space-y-3">
|
||||
<div class="flex items-center space-x-2 text-amber-700">
|
||||
<LucideIcon name="alert-triangle" :size="18" />
|
||||
<p class="font-semibold">预算提醒</p>
|
||||
</div>
|
||||
<ul class="space-y-2 text-sm text-amber-800">
|
||||
<li v-for="alert in budgetAlerts" :key="alert.id">
|
||||
「{{ alert.category }}」已使用 {{ Math.round((alert.usage / alert.amount) * 100) }}%,
|
||||
<span class="underline cursor-pointer" @click="navigateToBudgets">立即查看</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-gray-900">近期交易</h2>
|
||||
|
||||
@@ -7,6 +7,12 @@ import BudgetCard from '../../../components/budgets/BudgetCard.vue';
|
||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||
import { useCreateBudgetMutation, useDeleteBudgetMutation, useBudgetsQuery } from '../../../composables/useBudgets';
|
||||
import { useNotificationStatusQuery } from '../../../composables/useNotifications';
|
||||
import {
|
||||
useNotificationChannelsQuery,
|
||||
useCreateChannelMutation,
|
||||
useUpdateChannelMutation,
|
||||
useDeleteChannelMutation
|
||||
} from '../../../composables/useNotificationChannels';
|
||||
import { useAuthStore } from '../../../stores/auth';
|
||||
import { NotificationPermission } from '../../../lib/native/notification-permission';
|
||||
|
||||
@@ -44,6 +50,11 @@ const createBudget = useCreateBudgetMutation();
|
||||
const deleteBudget = useDeleteBudgetMutation();
|
||||
const notificationStatusQuery = useNotificationStatusQuery();
|
||||
const notificationStatus = computed(() => notificationStatusQuery.data.value);
|
||||
const channelsQuery = useNotificationChannelsQuery();
|
||||
const notificationChannels = computed(() => channelsQuery.data.value ?? []);
|
||||
const createChannel = useCreateChannelMutation();
|
||||
const updateChannel = useUpdateChannelMutation();
|
||||
const deleteChannel = useDeleteChannelMutation();
|
||||
|
||||
const budgetSheetOpen = ref(false);
|
||||
const budgetForm = reactive({
|
||||
@@ -224,6 +235,28 @@ const handleLogout = () => {
|
||||
authStore.clearSession();
|
||||
router.replace({ name: 'login', query: { redirect: '/' } });
|
||||
};
|
||||
|
||||
const newChannel = reactive({
|
||||
packageName: '',
|
||||
displayName: ''
|
||||
});
|
||||
|
||||
const handleCreateChannel = async () => {
|
||||
if (!newChannel.packageName || !newChannel.displayName) return;
|
||||
await createChannel.mutateAsync({ packageName: newChannel.packageName.trim(), displayName: newChannel.displayName.trim() });
|
||||
newChannel.packageName = '';
|
||||
newChannel.displayName = '';
|
||||
showPreferencesFeedback('已添加白名单');
|
||||
};
|
||||
|
||||
const toggleChannel = async (channelId: string, enabled: boolean) => {
|
||||
await updateChannel.mutateAsync({ id: channelId, payload: { enabled } });
|
||||
};
|
||||
|
||||
const removeChannel = async (channelId: string) => {
|
||||
await deleteChannel.mutateAsync(channelId);
|
||||
showPreferencesFeedback('已删除渠道');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -356,6 +389,64 @@ const handleLogout = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-800">白名单管理</p>
|
||||
<p class="text-xs text-gray-500">控制哪些应用可触发自动记账,并可为不同渠道配置模板。</p>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ channelsQuery.isFetching.value ? '同步中...' : `共 ${notificationChannels.length} 条` }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 md:flex-row">
|
||||
<input
|
||||
v-model="newChannel.packageName"
|
||||
type="text"
|
||||
placeholder="包名,例如 com.eg.android.AlipayGphone"
|
||||
class="flex-1 px-3 py-2 border border-gray-200 rounded-xl text-sm"
|
||||
/>
|
||||
<input
|
||||
v-model="newChannel.displayName"
|
||||
type="text"
|
||||
placeholder="展示名称"
|
||||
class="flex-1 px-3 py-2 border border-gray-200 rounded-xl text-sm"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 bg-indigo-500 text-white rounded-xl text-sm font-semibold disabled:opacity-60"
|
||||
:disabled="!newChannel.packageName || !newChannel.displayName || createChannel.isPending.value"
|
||||
@click="handleCreateChannel"
|
||||
>
|
||||
{{ createChannel.isPending.value ? '添加中...' : '添加' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2 max-h-60 overflow-y-auto">
|
||||
<div
|
||||
v-for="channel in notificationChannels"
|
||||
:key="channel.id"
|
||||
class="flex items-center justify-between border border-gray-100 rounded-xl px-4 py-3 bg-white"
|
||||
>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-800 text-sm">{{ channel.displayName }}</p>
|
||||
<p class="text-xs text-gray-500">{{ channel.packageName }}</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="flex items-center text-xs text-gray-500 space-x-1">
|
||||
<span>{{ channel.enabled ? '启用' : '停用' }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-10 h-5 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition"
|
||||
:checked="channel.enabled"
|
||||
@change="toggleChannel(channel.id, !channel.enabled)"
|
||||
/>
|
||||
</label>
|
||||
<button class="text-xs text-red-500" @click="removeChannel(channel.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="notificationChannels.length === 0" class="text-xs text-gray-400 text-center py-4">尚未配置白名单</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="border border-gray-100 rounded-2xl p-4">
|
||||
<p class="text-xs text-gray-500">通知入库</p>
|
||||
|
||||
177
apps/frontend/src/lib/storage/offline-db.ts
Normal file
177
apps/frontend/src/lib/storage/offline-db.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import {
|
||||
CapacitorSQLite,
|
||||
SQLiteConnection,
|
||||
type SQLiteDBConnection
|
||||
} from '@capacitor-community/sqlite';
|
||||
import type { Transaction } from '../../types/transaction';
|
||||
import type { TransactionPayload } from '../../types/transaction';
|
||||
|
||||
const isNative = Capacitor.isNativePlatform();
|
||||
|
||||
let sqlite: SQLiteConnection | null = null;
|
||||
let db: SQLiteDBConnection | null = null;
|
||||
|
||||
const WEB_STORAGE_KEY = 'ai-bill-offline-transactions';
|
||||
const PENDING_KEY = 'ai-bill-pending-transactions';
|
||||
const canUseLocalStorage = typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
|
||||
async function getDb(): Promise<SQLiteDBConnection | null> {
|
||||
if (!isNative) return null;
|
||||
if (!sqlite) {
|
||||
sqlite = new SQLiteConnection(CapacitorSQLite);
|
||||
}
|
||||
if (!db) {
|
||||
db = await sqlite.createConnection('ai_bill_offline', false, 'no-encryption', 1, false);
|
||||
await db.open();
|
||||
await db.execute(
|
||||
`CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
amount REAL,
|
||||
currency TEXT,
|
||||
category TEXT,
|
||||
type TEXT,
|
||||
source TEXT,
|
||||
status TEXT,
|
||||
occurredAt TEXT,
|
||||
notes TEXT,
|
||||
metadata TEXT
|
||||
);`
|
||||
);
|
||||
await db.execute(
|
||||
`CREATE TABLE IF NOT EXISTS pending_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT
|
||||
);`
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function persistTransactionsOffline(transactions: Transaction[]): Promise<void> {
|
||||
const database = await getDb();
|
||||
if (!database) {
|
||||
if (canUseLocalStorage) {
|
||||
localStorage.setItem(WEB_STORAGE_KEY, JSON.stringify(transactions));
|
||||
}
|
||||
return;
|
||||
}
|
||||
await database.execute('DELETE FROM transactions;');
|
||||
const statements = transactions.map((txn) => ({
|
||||
statement:
|
||||
'INSERT OR REPLACE INTO transactions (id, title, amount, currency, category, type, source, status, occurredAt, notes, metadata) VALUES (?,?,?,?,?,?,?,?,?,?,?);',
|
||||
values: [
|
||||
txn.id,
|
||||
txn.title,
|
||||
txn.amount,
|
||||
txn.currency,
|
||||
txn.category,
|
||||
txn.type,
|
||||
txn.source,
|
||||
txn.status,
|
||||
txn.occurredAt,
|
||||
txn.notes ?? null,
|
||||
JSON.stringify(txn.metadata ?? {})
|
||||
]
|
||||
}));
|
||||
await database.executeSet(statements);
|
||||
}
|
||||
|
||||
export async function readTransactionsOffline(): Promise<Transaction[]> {
|
||||
const database = await getDb();
|
||||
if (!database) {
|
||||
if (!canUseLocalStorage) return [];
|
||||
const stored = localStorage.getItem(WEB_STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as Transaction[]) : [];
|
||||
}
|
||||
const result = await database.query('SELECT * FROM transactions ORDER BY occurredAt DESC;');
|
||||
return (
|
||||
result.values?.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
amount: row.amount,
|
||||
currency: row.currency,
|
||||
category: row.category,
|
||||
type: row.type,
|
||||
source: row.source,
|
||||
status: row.status,
|
||||
occurredAt: row.occurredAt,
|
||||
notes: row.notes ?? undefined,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
const randomId = () => {
|
||||
const uuid =
|
||||
typeof globalThis.crypto !== 'undefined' && typeof globalThis.crypto.randomUUID === 'function'
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
return `pending_${uuid}`;
|
||||
};
|
||||
|
||||
export async function queuePendingTransaction(payload: TransactionPayload): Promise<void> {
|
||||
const database = await getDb();
|
||||
const item = { id: randomId(), payload };
|
||||
if (!database) {
|
||||
if (!canUseLocalStorage) return;
|
||||
const stored = localStorage.getItem(PENDING_KEY);
|
||||
const list: typeof item[] = stored ? JSON.parse(stored) : [];
|
||||
list.push(item);
|
||||
localStorage.setItem(PENDING_KEY, JSON.stringify(list));
|
||||
return;
|
||||
}
|
||||
await database.run('INSERT OR REPLACE INTO pending_transactions (id, payload) VALUES (?, ?);', [
|
||||
item.id,
|
||||
JSON.stringify(payload)
|
||||
]);
|
||||
}
|
||||
|
||||
async function readPendingTransactions(): Promise<Array<{ id: string; payload: TransactionPayload }>> {
|
||||
const database = await getDb();
|
||||
if (!database) {
|
||||
if (!canUseLocalStorage) return [];
|
||||
const stored = localStorage.getItem(PENDING_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
}
|
||||
const result = await database.query('SELECT * FROM pending_transactions;');
|
||||
return (
|
||||
result.values?.map((row) => ({
|
||||
id: row.id,
|
||||
payload: JSON.parse(row.payload)
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
async function removePendingTransaction(id: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
if (!database) {
|
||||
if (!canUseLocalStorage) return;
|
||||
const stored = localStorage.getItem(PENDING_KEY);
|
||||
if (!stored) return;
|
||||
const next = (JSON.parse(stored) as Array<{ id: string; payload: TransactionPayload }>).filter(
|
||||
(item) => item.id !== id
|
||||
);
|
||||
localStorage.setItem(PENDING_KEY, JSON.stringify(next));
|
||||
return;
|
||||
}
|
||||
await database.run('DELETE FROM pending_transactions WHERE id = ?;', [id]);
|
||||
}
|
||||
|
||||
export async function syncPendingTransactions(
|
||||
uploader: (payload: TransactionPayload) => Promise<void>
|
||||
): Promise<number> {
|
||||
const pending = await readPendingTransactions();
|
||||
let synced = 0;
|
||||
for (const item of pending) {
|
||||
try {
|
||||
await uploader(item.payload);
|
||||
await removePendingTransaction(item.id);
|
||||
synced += 1;
|
||||
} catch (_error) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return synced;
|
||||
}
|
||||
13
apps/frontend/src/types/notification.ts
Normal file
13
apps/frontend/src/types/notification.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface NotificationChannel {
|
||||
id: string;
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
enabled: boolean;
|
||||
templates: Array<{
|
||||
name?: string;
|
||||
keywords?: string[];
|
||||
category?: string;
|
||||
type?: 'income' | 'expense';
|
||||
amountPattern?: string;
|
||||
}>;
|
||||
}
|
||||
Reference in New Issue
Block a user