feat:新增离线存储
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ apps/backend/dist
|
||||
*.local
|
||||
.env
|
||||
.DS_Store
|
||||
design
|
||||
67
README.md
67
README.md
@@ -15,25 +15,30 @@
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. 安装依赖(首次执行已完成可跳过):
|
||||
1. 复制环境变量模板:
|
||||
```bash
|
||||
cp apps/backend/.env.example apps/backend/.env.local
|
||||
```
|
||||
若使用本地 MongoDB,请根据实际连接串调整 `MONGODB_URI`(例如 `mongodb://127.0.0.1:27017/ai-bill`)。
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
2. (可选)启动内置 MongoDB(需 Docker 环境):
|
||||
3. (可选)启动内置 MongoDB(需 Docker 环境):
|
||||
```bash
|
||||
pnpm db:up
|
||||
pnpm --filter backend seed # 首次运行:写入默认用户/预算/交易
|
||||
```
|
||||
结束后可通过 `pnpm db:down` 停止容器。
|
||||
3. 启动后端(默认端口 4000):
|
||||
4. 启动后端(默认端口 4000):
|
||||
```bash
|
||||
pnpm dev:backend
|
||||
```
|
||||
4. 启动前端(默认端口 5173):
|
||||
5. 启动前端(默认端口 5173):
|
||||
```bash
|
||||
pnpm dev:frontend
|
||||
```
|
||||
5. 或者并行启动:
|
||||
6. 或者并行启动:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
@@ -41,7 +46,8 @@
|
||||
## 后端说明(apps/backend)
|
||||
- 技术栈:Express、TypeScript、Zod、Mongoose、Pino。
|
||||
- `.env.example` 给出了基础环境变量;开发时可复制为 `.env.local`。
|
||||
- 支持 MongoDB 或内存双模式运行:当 `MONGODB_URI` 未设置时自动回退到示例数据,方便离线体验。若使用内置 Docker 服务,可配置 `MONGODB_URI=mongodb://root:example@127.0.0.1:27017/ai-bill?authSource=admin`。
|
||||
- **必须** 连接 MongoDB:如使用内置 `docker-compose`,可直接使用 `MONGODB_URI=mongodb://mongo:27017/ai-bill`。
|
||||
- 所有 `/api` 接口(除 `/api/auth/*` 与签名校验的 `/api/transactions/notification`)均需要 `Authorization: Bearer <accessToken>` 访问。
|
||||
- 核心 API:
|
||||
- `POST /api/auth/login|register|refresh|logout|forgot-password`
|
||||
- `GET /api/transactions`、`POST /api/transactions`、`PATCH /api/transactions/:id`、`DELETE /api/transactions/:id`
|
||||
@@ -51,6 +57,20 @@
|
||||
- `GET /api/notifications/status`(返回 webhook 地址、密钥提示、通知包名白名单及最近入库信息)
|
||||
- `pnpm --filter backend build` 进行编译,`pnpm --filter backend lint` 执行 ESLint。
|
||||
|
||||
### Docker Compose 快速部署
|
||||
|
||||
项目根目录提供 `Dockerfile` 与 `docker-compose.yml`,可一键启动 MongoDB + 后端服务:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
默认暴露端口:
|
||||
- `mongodb://localhost:27017`(数据库名 `ai-bill`)
|
||||
- `http://localhost:4000/api`(后端 API)
|
||||
|
||||
请在生产环境中覆盖 `docker-compose.yml` 中的占位密钥(`JWT_SECRET`、`NOTIFICATION_WEBHOOK_SECRET` 等),或使用 `env_file` 注入。
|
||||
|
||||
## 前端说明(apps/frontend)
|
||||
- 技术栈:Vue 3、TypeScript、Pinia、Vue Router、@tanstack/vue-query、TailwindCSS。
|
||||
- UI 与交互设计遵循 `UI设计规范 design_system.md` 与 `ui_mockup.html`。
|
||||
@@ -74,6 +94,41 @@
|
||||
- 补充自动化测试:Vitest(前端组件/状态)、Jest + Supertest(后端 API)、Playwright(端到端流程)。
|
||||
- 优化移动端体验:离线缓存、渐进式加载、通知权限引导流程。
|
||||
|
||||
## 部署指引(后端)
|
||||
|
||||
### 方案一:自建服务器 + Docker Compose
|
||||
1. 准备 `.env` 文件(建议基于 `apps/backend/.env.example` 修改),确保密钥使用随机字符串:
|
||||
```
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=4000
|
||||
MONGODB_URI=mongodb://mongo:27017/ai-bill
|
||||
JWT_SECRET=<随机 64 位字符串>
|
||||
JWT_REFRESH_SECRET=<随机 64 位字符串>
|
||||
NOTIFICATION_WEBHOOK_SECRET=<与客户端一致的 HMAC 密钥>
|
||||
```
|
||||
2. 运行 `docker compose up -d`,Dockerfile 会自动构建并启动后端。
|
||||
3. 如需对外提供 HTTPS,可在服务器前再挂一层 Nginx/Traefik。
|
||||
|
||||
### 方案二:托管平台(Railway / Fly.io / Render 等)
|
||||
1. 连接 Git 仓库,选择 Node 运行时;
|
||||
2. 配置上述环境变量;
|
||||
3. 构建命令 `pnpm install && pnpm --filter backend build`;启动命令 `node apps/backend/dist/main.js`;
|
||||
4. 平台提供的 HTTPS 域名可直接写入前端/安卓配置。
|
||||
|
||||
### 部署后验证
|
||||
1. 访问 `GET /api/health`(或 `/api/notifications/status`)确认服务正常;
|
||||
2. 更新前端配置:`VITE_API_BASE_URL`、`gradle.properties`(Android)指向云端地址;
|
||||
3. 使用 curl 或 Android 真机测试通知上报流程。
|
||||
|
||||
## 下一步规划(roadmap)
|
||||
- **数据解析**:完善通知正则识别逻辑、自动识别金额/类型/分类、提供幂等处理;
|
||||
- **多用户与权限**:前后端对接真实登录,支持注销、Token 刷新、路由守卫;
|
||||
- **体验优化**:交易/预算手动刷新、错误反馈 Toast、个性化偏好按钮交互优化;
|
||||
- **原生能力**:Android 完善通知权限引导、OCR 插件、摄像头票据扫描;
|
||||
- **部署与监控**:完善 Dockerfile/CI/CD、引入日志告警、Grafana/Prometheus 监控;
|
||||
- **扩展功能**:预算提醒、AI 分析历史记录、账本导出、团队共享账本。
|
||||
|
||||
## 功能调试指南
|
||||
|
||||
### 通知监听接口调试
|
||||
|
||||
27
apps/backend/src/models/notification-channel.model.ts
Normal file
27
apps/backend/src/models/notification-channel.model.ts
Normal 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);
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
149
pnpm-lock.yaml
generated
149
pnpm-lock.yaml
generated
@@ -95,6 +95,9 @@ importers:
|
||||
|
||||
apps/frontend:
|
||||
dependencies:
|
||||
'@capacitor-community/sqlite':
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2(@capacitor/core@7.4.4)
|
||||
'@capacitor/android':
|
||||
specifier: ^7.4.4
|
||||
version: 7.4.4(@capacitor/core@7.4.4)
|
||||
@@ -195,6 +198,16 @@ packages:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
/@capacitor-community/sqlite@7.0.2(@capacitor/core@7.4.4):
|
||||
resolution: {integrity: sha512-TkxvuLgVCSmme/NSUEE2xopTVMwMqdT6+zH9pH8g76DMdi2z+ZsPEATQnJchGw4YAhhiQ8vTYxIP9se3/zkhGg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
peerDependencies:
|
||||
'@capacitor/core': '>=7.0.0'
|
||||
dependencies:
|
||||
'@capacitor/core': 7.4.4
|
||||
jeep-sqlite: 2.8.0
|
||||
dev: false
|
||||
|
||||
/@capacitor/android@7.4.4(@capacitor/core@7.4.4):
|
||||
resolution: {integrity: sha512-y8knfV1JXNrd6XZZLZireGT+EBCN0lvOo+HZ/s7L8LkrPBu4nY5UZn0Wxz4yOezItEII9rqYJSHsS5fMJG9gdw==}
|
||||
peerDependencies:
|
||||
@@ -885,6 +898,14 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-darwin-arm64@4.34.9:
|
||||
resolution: {integrity: sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-darwin-arm64@4.52.5:
|
||||
resolution: {integrity: sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==}
|
||||
cpu: [arm64]
|
||||
@@ -893,6 +914,14 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-darwin-x64@4.34.9:
|
||||
resolution: {integrity: sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-darwin-x64@4.52.5:
|
||||
resolution: {integrity: sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==}
|
||||
cpu: [x64]
|
||||
@@ -933,6 +962,14 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-linux-arm64-gnu@4.34.9:
|
||||
resolution: {integrity: sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-linux-arm64-gnu@4.52.5:
|
||||
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
|
||||
cpu: [arm64]
|
||||
@@ -941,6 +978,14 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-linux-arm64-musl@4.34.9:
|
||||
resolution: {integrity: sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-linux-arm64-musl@4.52.5:
|
||||
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
|
||||
cpu: [arm64]
|
||||
@@ -989,6 +1034,14 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-linux-x64-gnu@4.34.9:
|
||||
resolution: {integrity: sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-linux-x64-gnu@4.52.5:
|
||||
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
|
||||
cpu: [x64]
|
||||
@@ -997,6 +1050,14 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-linux-x64-musl@4.34.9:
|
||||
resolution: {integrity: sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-linux-x64-musl@4.52.5:
|
||||
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
|
||||
cpu: [x64]
|
||||
@@ -1013,6 +1074,14 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-win32-arm64-msvc@4.34.9:
|
||||
resolution: {integrity: sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-win32-arm64-msvc@4.52.5:
|
||||
resolution: {integrity: sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==}
|
||||
cpu: [arm64]
|
||||
@@ -1037,6 +1106,14 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-win32-x64-msvc@4.34.9:
|
||||
resolution: {integrity: sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-win32-x64-msvc@4.52.5:
|
||||
resolution: {integrity: sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==}
|
||||
cpu: [x64]
|
||||
@@ -1045,6 +1122,21 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@stencil/core@4.38.3:
|
||||
resolution: {integrity: sha512-rSDzGUfi58X8K79ySjlM6KlY+mq7D+ittzgNAdYHcsXHc70sBpdatFhnbOg25uVDiMf7xRAH9slP38pPdXnZOQ==}
|
||||
engines: {node: '>=16.0.0', npm: '>=7.10.0'}
|
||||
hasBin: true
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-darwin-arm64': 4.34.9
|
||||
'@rollup/rollup-darwin-x64': 4.34.9
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.34.9
|
||||
'@rollup/rollup-linux-arm64-musl': 4.34.9
|
||||
'@rollup/rollup-linux-x64-gnu': 4.34.9
|
||||
'@rollup/rollup-linux-x64-musl': 4.34.9
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.34.9
|
||||
'@rollup/rollup-win32-x64-msvc': 4.34.9
|
||||
dev: false
|
||||
|
||||
/@tanstack/match-sorter-utils@8.19.4:
|
||||
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1913,6 +2005,10 @@ packages:
|
||||
fill-range: 7.1.1
|
||||
dev: true
|
||||
|
||||
/browser-fs-access@0.35.0:
|
||||
resolution: {integrity: sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==}
|
||||
dev: false
|
||||
|
||||
/browserslist@4.27.0:
|
||||
resolution: {integrity: sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
@@ -3408,6 +3504,10 @@ packages:
|
||||
engines: {node: '>= 4'}
|
||||
dev: true
|
||||
|
||||
/immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
dev: false
|
||||
|
||||
/import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3566,6 +3666,16 @@ packages:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
dev: false
|
||||
|
||||
/jeep-sqlite@2.8.0:
|
||||
resolution: {integrity: sha512-FWNUP6OAmrUHwiW7H1xH5YUQ8tN2O4l4psT1sLd7DQtHd5PfrA1nvNdeKPNj+wQBtu7elJa8WoUibTytNTaaCg==}
|
||||
dependencies:
|
||||
'@stencil/core': 4.38.3
|
||||
browser-fs-access: 0.35.0
|
||||
jszip: 3.10.1
|
||||
localforage: 1.10.0
|
||||
sql.js: 1.13.0
|
||||
dev: false
|
||||
|
||||
/jiti@1.21.7:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
@@ -3639,6 +3749,15 @@ packages:
|
||||
semver: 7.7.3
|
||||
dev: false
|
||||
|
||||
/jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
dev: false
|
||||
|
||||
/jwa@1.4.2:
|
||||
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
||||
dependencies:
|
||||
@@ -3686,6 +3805,18 @@ packages:
|
||||
type-check: 0.4.0
|
||||
dev: true
|
||||
|
||||
/lie@3.1.1:
|
||||
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
dev: false
|
||||
|
||||
/lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
dev: false
|
||||
|
||||
/lilconfig@3.1.3:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -3705,6 +3836,12 @@ packages:
|
||||
strip-bom: 3.0.0
|
||||
dev: true
|
||||
|
||||
/localforage@1.10.0:
|
||||
resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==}
|
||||
dependencies:
|
||||
lie: 3.1.1
|
||||
dev: false
|
||||
|
||||
/locate-path@2.0.0:
|
||||
resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4311,6 +4448,10 @@ packages:
|
||||
/package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
/pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
dev: false
|
||||
|
||||
/parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5003,6 +5144,10 @@ packages:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
dev: true
|
||||
|
||||
/setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
dev: false
|
||||
|
||||
/setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
@@ -5197,6 +5342,10 @@ packages:
|
||||
through: 2.3.8
|
||||
dev: true
|
||||
|
||||
/sql.js@1.13.0:
|
||||
resolution: {integrity: sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==}
|
||||
dev: false
|
||||
|
||||
/statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
Reference in New Issue
Block a user