feat:新增离线存储

This commit is contained in:
2025-11-10 14:14:51 +08:00
parent 2e2caeaab5
commit ba757017ae
20 changed files with 969 additions and 20 deletions

View File

@@ -0,0 +1,27 @@
import mongoose, { type InferSchemaType } from 'mongoose';
const notificationTemplateSchema = new mongoose.Schema(
{
name: { type: String },
keywords: { type: [String], default: [] },
category: { type: String },
type: { type: String, enum: ['income', 'expense'] },
amountPattern: { type: String }
},
{ _id: false }
);
const notificationChannelSchema = new mongoose.Schema(
{
packageName: { type: String, required: true, unique: true },
displayName: { type: String, required: true },
enabled: { type: Boolean, default: true },
templates: { type: [notificationTemplateSchema], default: [] }
},
{ timestamps: true }
);
export type NotificationChannelDocument = InferSchemaType<typeof notificationChannelSchema>;
export const NotificationChannelModel =
mongoose.models.NotificationChannel ?? mongoose.model('NotificationChannel', notificationChannelSchema);

View File

@@ -1,5 +1,5 @@
import type { Request, Response } from 'express';
import { listBudgets, createBudget, updateBudget, deleteBudget } from './budgets.service.js';
import { listBudgets, createBudget, updateBudget, deleteBudget, syncBudgetUsage } from './budgets.service.js';
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
export const listBudgetsHandler = async (req: Request, res: Response) => {
@@ -16,6 +16,7 @@ export const createBudgetHandler = async (req: Request, res: Response) => {
}
const payload = req.body as CreateBudgetInput;
const budget = await createBudget(payload, req.user.id);
await syncBudgetUsage(req.user.id, [budget.category]);
return res.status(201).json({ data: budget });
};
@@ -28,6 +29,7 @@ export const updateBudgetHandler = async (req: Request, res: Response) => {
if (!budget) {
return res.status(404).json({ message: 'Budget not found' });
}
await syncBudgetUsage(req.user.id, [budget.category]);
return res.json({ data: budget });
};

View File

@@ -1,5 +1,6 @@
import mongoose from 'mongoose';
import { BudgetModel, type BudgetDocument } from '../../models/budget.model.js';
import { TransactionModel } from '../../models/transaction.model.js';
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
export interface BudgetDto {
@@ -72,3 +73,57 @@ export async function deleteBudget(id: string, userId: string): Promise<boolean>
const result = await BudgetModel.findOneAndDelete({ _id: id, userId }).exec();
return result !== null;
}
const getPeriodRange = (period: 'monthly' | 'weekly') => {
const now = new Date();
let start: Date;
if (period === 'weekly') {
const day = now.getDay();
const diff = day === 0 ? 6 : day - 1; // Monday start
start = new Date(now);
start.setDate(now.getDate() - diff);
start.setHours(0, 0, 0, 0);
} else {
start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
}
const end = new Date(now);
end.setHours(23, 59, 59, 999);
return { start, end };
};
async function calculateUsageForBudget(budget: BudgetDocumentWithId, userId: string): Promise<number> {
const { start, end } = getPeriodRange(budget.period);
const userObjectId = new mongoose.Types.ObjectId(userId);
const [result] = await TransactionModel.aggregate<{ total: number }>([
{
$match: {
userId: userObjectId,
type: 'expense',
status: { $ne: 'rejected' },
category: budget.category,
occurredAt: { $gte: start, $lte: end }
}
},
{
$group: { _id: null, total: { $sum: '$amount' } }
}
])
.allowDiskUse(false)
.exec();
return Number(result?.total ?? 0);
}
export async function syncBudgetUsage(userId: string, categories?: string[]): Promise<void> {
const filter: Record<string, unknown> = { userId };
if (categories && categories.length > 0) {
filter.category = { $in: categories };
}
const budgets = await BudgetModel.find(filter).lean<BudgetDocumentWithId[]>().exec();
await Promise.all(
budgets.map(async (budget) => {
const usage = await calculateUsageForBudget(budget, userId);
await BudgetModel.updateOne({ _id: budget._id }, { usage }).exec();
})
);
}

View File

@@ -0,0 +1,100 @@
import mongoose from 'mongoose';
import { NotificationChannelModel, type NotificationChannelDocument } from '../../models/notification-channel.model.js';
import { env } from '../../config/env.js';
export interface NotificationChannelDto {
id: string;
packageName: string;
displayName: string;
enabled: boolean;
templates: Array<{
name?: string;
keywords?: string[];
category?: string;
type?: 'income' | 'expense';
amountPattern?: string;
}>;
}
const toDto = (doc: NotificationChannelDocument & { _id: mongoose.Types.ObjectId }): NotificationChannelDto => ({
id: doc._id.toString(),
packageName: doc.packageName,
displayName: doc.displayName,
enabled: doc.enabled,
templates: doc.templates ?? []
});
export async function ensureDefaultChannels(): Promise<void> {
if ((await NotificationChannelModel.estimatedDocumentCount().exec()) > 0) {
return;
}
const defaults = env.notificationPackageWhitelist.map((pkg) => ({
packageName: pkg,
displayName: pkg,
enabled: true,
templates: []
}));
if (defaults.length > 0) {
await NotificationChannelModel.insertMany(defaults, { ordered: false });
}
}
export async function listChannels(): Promise<NotificationChannelDto[]> {
const docs = await NotificationChannelModel.find().sort({ displayName: 1 }).lean().exec();
return docs.map((doc) => toDto(doc as NotificationChannelDocument & { _id: mongoose.Types.ObjectId }));
}
export async function upsertChannel(input: {
packageName: string;
displayName: string;
enabled?: boolean;
templates?: NotificationChannelDto['templates'];
}): Promise<NotificationChannelDto> {
const updated = await NotificationChannelModel.findOneAndUpdate(
{ packageName: input.packageName },
{
$set: {
displayName: input.displayName,
enabled: input.enabled ?? true,
templates: input.templates ?? []
}
},
{ new: true, upsert: true }
)
.lean<NotificationChannelDocument & { _id: mongoose.Types.ObjectId }>()
.exec();
if (!updated) {
throw new Error('Failed to upsert channel');
}
return toDto(updated);
}
export async function updateChannel(
id: string,
payload: Partial<Omit<NotificationChannelDto, 'id'>>
): Promise<NotificationChannelDto | null> {
const updated = await NotificationChannelModel.findByIdAndUpdate(
id,
{ $set: payload },
{ new: true }
)
.lean<NotificationChannelDocument & { _id: mongoose.Types.ObjectId }>()
.exec();
return updated ? toDto(updated) : null;
}
export async function deleteChannel(id: string): Promise<boolean> {
const result = await NotificationChannelModel.findByIdAndDelete(id).exec();
return result !== null;
}
export async function getChannelByPackage(
packageName: string
): Promise<(NotificationChannelDocument & { _id: mongoose.Types.ObjectId }) | null> {
const doc = await NotificationChannelModel.findOne({ packageName }).lean().exec();
if (!doc) return null;
return doc as NotificationChannelDocument & { _id: mongoose.Types.ObjectId };
}

View File

@@ -1,6 +1,13 @@
import type { Request, Response } from 'express';
import { env } from '../../config/env.js';
import { TransactionModel } from '../../models/transaction.model.js';
import type { NotificationChannelDto } from './notification-channels.service.js';
import {
deleteChannel,
listChannels,
upsertChannel,
updateChannel
} from './notification-channels.service.js';
const maskSecret = (secret?: string | null) => {
if (!secret) return null;
@@ -19,12 +26,14 @@ export const getNotificationStatus = async (req: Request, res: Response) => {
| null;
const ingestEndpoint = `${req.protocol}://${req.get('host') ?? 'localhost'}/api/transactions/notification`;
const channels = await listChannels();
const whitelist = channels.filter((channel) => channel.enabled).map((channel) => channel.packageName);
const secret = env.NOTIFICATION_WEBHOOK_SECRET;
const response = {
secretConfigured: Boolean(secret),
secretHint: maskSecret(secret),
webhookSecret: env.isProduction ? null : secret ?? null,
packageWhitelist: env.notificationPackageWhitelist,
packageWhitelist: whitelist,
ingestedCount: count,
lastNotificationAt: latest?.createdAt ? new Date(latest.createdAt).toISOString() : null,
ingestEndpoint
@@ -32,3 +41,35 @@ export const getNotificationStatus = async (req: Request, res: Response) => {
return res.json(response);
};
export const listChannelsHandler = async (_req: Request, res: Response) => {
const data = await listChannels();
return res.json({ data });
};
export const upsertChannelHandler = async (req: Request, res: Response) => {
const payload = req.body as {
packageName: string;
displayName: string;
enabled?: boolean;
templates?: NotificationChannelDto['templates'];
};
const channel = await upsertChannel(payload);
return res.status(201).json({ data: channel });
};
export const updateChannelHandler = async (req: Request, res: Response) => {
const channel = await updateChannel(req.params.id, req.body);
if (!channel) {
return res.status(404).json({ message: 'Channel not found' });
}
return res.json({ data: channel });
};
export const deleteChannelHandler = async (req: Request, res: Response) => {
const removed = await deleteChannel(req.params.id);
if (!removed) {
return res.status(404).json({ message: 'Channel not found' });
}
return res.status(204).send();
};

View File

@@ -1,11 +1,66 @@
import { Router } from 'express';
import { z } from 'zod';
import { authenticate } from '../../middlewares/authenticate.js';
import { getNotificationStatus } from './notifications.controller.js';
import { getNotificationStatus, listChannelsHandler, upsertChannelHandler, updateChannelHandler, deleteChannelHandler } from './notifications.controller.js';
import { validateRequest } from '../../middlewares/validate-request.js';
import { ensureDefaultChannels } from './notification-channels.service.js';
const router = Router();
router.use(authenticate);
router.get('/status', getNotificationStatus);
router.get('/channels', listChannelsHandler);
router.post(
'/channels',
validateRequest({
body: z.object({
packageName: z.string().min(1),
displayName: z.string().min(1),
enabled: z.boolean().optional(),
templates: z
.array(
z.object({
name: z.string().optional(),
keywords: z.array(z.string()).optional(),
category: z.string().optional(),
type: z.enum(['income', 'expense']).optional(),
amountPattern: z.string().optional()
})
)
.optional()
})
}),
upsertChannelHandler
);
router.patch(
'/channels/:id',
validateRequest({
params: z.object({ id: z.string().min(1) }),
body: z.object({
displayName: z.string().min(1).optional(),
enabled: z.boolean().optional(),
templates: z
.array(
z.object({
name: z.string().optional(),
keywords: z.array(z.string()).optional(),
category: z.string().optional(),
type: z.enum(['income', 'expense']).optional(),
amountPattern: z.string().optional()
})
)
.optional()
})
}),
updateChannelHandler
);
router.delete(
'/channels/:id',
validateRequest({ params: z.object({ id: z.string().min(1) }) }),
deleteChannelHandler
);
void ensureDefaultChannels();
export default router;

View File

@@ -10,7 +10,19 @@ export interface ParsedNotification {
confidence?: number;
}
export function parseNotificationPayload(title: string, body: string): ParsedNotification {
export interface ChannelTemplateOverrides {
name?: string;
keywords?: string[];
category?: string;
type?: 'income' | 'expense';
amountPattern?: string;
}
export function parseNotificationPayload(
title: string,
body: string,
template?: ChannelTemplateOverrides | null
): ParsedNotification {
const combined = `${title}|${body}`;
const parsed: ParsedNotification = {
@@ -27,11 +39,28 @@ export function parseNotificationPayload(title: string, body: string): ParsedNot
parsed.ruleId = classification.ruleId;
parsed.confidence = classification.confidence;
const amount = extractAmountFromText(title, body);
let amount = extractAmountFromText(title, body);
if (!amount && template?.amountPattern) {
const customRegex = new RegExp(template.amountPattern);
const match = body.match(customRegex);
if (match?.[1]) {
const candidate = Number.parseFloat(match[1]);
if (!Number.isNaN(candidate)) {
amount = Math.abs(candidate);
}
}
}
if (amount !== undefined) {
parsed.amount = amount;
}
if (template?.category) {
parsed.category = template.category;
}
if (template?.type) {
parsed.type = template.type;
}
if (!parsed.type) {
if (combined.includes('到账') || combined.includes('收入') || combined.includes('入账')) {
parsed.type = 'income';

View File

@@ -1,6 +1,9 @@
import mongoose from 'mongoose';
import { createHash } from 'node:crypto';
import { TransactionModel, type TransactionDocument } from '../../models/transaction.model.js';
import { classifyByText } from '../../services/classification.service.js';
import { syncBudgetUsage } from '../budgets/budgets.service.js';
import { getChannelByPackage } from '../notifications/notification-channels.service.js';
import { getDefaultUserId } from '../../services/user-context.js';
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
import { parseNotificationPayload } from './notification.parser.js';
@@ -93,6 +96,9 @@ export async function createTransaction(payload: CreateTransactionInput, userId?
if (!createdDoc) {
throw new Error('Failed to load created transaction');
}
if (createdDoc.type === 'expense' && createdDoc.category) {
await syncBudgetUsage(resolvedUserId, [createdDoc.category]);
}
return toDto(createdDoc);
}
@@ -126,11 +132,20 @@ export async function updateTransaction(
.lean<TransactionDocumentWithId>()
.exec();
if (updated && updated.type === 'expense' && updated.category) {
await syncBudgetUsage(userId, [updated.category]);
}
return updated ? toDto(updated) : null;
}
export async function deleteTransaction(id: string, userId: string): Promise<boolean> {
const result = await TransactionModel.findOneAndDelete({ _id: id, userId }).exec();
const result = await TransactionModel.findOneAndDelete({ _id: id, userId })
.lean<TransactionDocumentWithId>()
.exec();
if (result && result.type === 'expense' && result.category) {
await syncBudgetUsage(userId, [result.category]);
}
return result !== null;
}
@@ -143,7 +158,29 @@ interface NotificationPayload {
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
const now = payload.receivedAt;
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '');
const channel = await getChannelByPackage(payload.packageName);
if (!channel || !channel.enabled) {
throw Object.assign(new Error('Notification channel disabled'), { statusCode: 403 });
}
const template =
channel.templates?.find((tpl: { keywords?: string[] }) =>
tpl.keywords?.some((keyword: string) => keyword && (payload.title?.includes(keyword) || payload.body?.includes(keyword)))
) ?? null;
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', template);
const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`;
const notificationHash = createHash('sha256').update(hashSource).digest('hex');
const existing = await TransactionModel.findOne({
'metadata.notificationHash': notificationHash
})
.lean<TransactionDocumentWithId>()
.exec();
if (existing) {
return toDto(existing);
}
const transactionPayload: CreateTransactionInput = {
title: payload.title,
@@ -158,7 +195,9 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
metadata: {
packageName: payload.packageName,
rawBody: payload.body,
parser: parsed
parser: parsed,
channelId: channel._id.toString(),
notificationHash
}
};

View File

@@ -4,11 +4,13 @@ import android.os.Bundle;
import com.bill.ai.notification.NotificationPermissionPlugin;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.community.database.sqlite.CapacitorSQLite;
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(NotificationPermissionPlugin.class);
registerPlugin(CapacitorSQLite.class);
super.onCreate(savedInstanceState);
}
}

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@capacitor-community/sqlite": "^7.0.2",
"@capacitor/android": "^7.4.4",
"@capacitor/app": "^7.1.0",
"@capacitor/cli": "^7.4.4",

View File

@@ -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>

View 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 });
}
});
}

View File

@@ -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 });
}
});

View File

@@ -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>

View File

@@ -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>

View 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;
}

View 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;
}>;
}