diff --git a/.gitignore b/.gitignore index 2917600..7fe9eed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ apps/backend/dist *.local .env .DS_Store +design \ No newline at end of file diff --git a/README.md b/README.md index 4e66be8..6efa2a2 100644 --- a/README.md +++ b/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 ` 访问。 - 核心 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 分析历史记录、账本导出、团队共享账本。 + ## 功能调试指南 ### 通知监听接口调试 diff --git a/apps/backend/src/models/notification-channel.model.ts b/apps/backend/src/models/notification-channel.model.ts new file mode 100644 index 0000000..0311114 --- /dev/null +++ b/apps/backend/src/models/notification-channel.model.ts @@ -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; + +export const NotificationChannelModel = + mongoose.models.NotificationChannel ?? mongoose.model('NotificationChannel', notificationChannelSchema); diff --git a/apps/backend/src/modules/budgets/budgets.controller.ts b/apps/backend/src/modules/budgets/budgets.controller.ts index 72c238a..421c281 100644 --- a/apps/backend/src/modules/budgets/budgets.controller.ts +++ b/apps/backend/src/modules/budgets/budgets.controller.ts @@ -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 }); }; diff --git a/apps/backend/src/modules/budgets/budgets.service.ts b/apps/backend/src/modules/budgets/budgets.service.ts index 9569b2f..90373e7 100644 --- a/apps/backend/src/modules/budgets/budgets.service.ts +++ b/apps/backend/src/modules/budgets/budgets.service.ts @@ -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 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 { + 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 { + const filter: Record = { userId }; + if (categories && categories.length > 0) { + filter.category = { $in: categories }; + } + const budgets = await BudgetModel.find(filter).lean().exec(); + await Promise.all( + budgets.map(async (budget) => { + const usage = await calculateUsageForBudget(budget, userId); + await BudgetModel.updateOne({ _id: budget._id }, { usage }).exec(); + }) + ); +} diff --git a/apps/backend/src/modules/notifications/notification-channels.service.ts b/apps/backend/src/modules/notifications/notification-channels.service.ts new file mode 100644 index 0000000..26e8ab5 --- /dev/null +++ b/apps/backend/src/modules/notifications/notification-channels.service.ts @@ -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 { + 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 { + 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 { + const updated = await NotificationChannelModel.findOneAndUpdate( + { packageName: input.packageName }, + { + $set: { + displayName: input.displayName, + enabled: input.enabled ?? true, + templates: input.templates ?? [] + } + }, + { new: true, upsert: true } + ) + .lean() + .exec(); + + if (!updated) { + throw new Error('Failed to upsert channel'); + } + return toDto(updated); +} + +export async function updateChannel( + id: string, + payload: Partial> +): Promise { + const updated = await NotificationChannelModel.findByIdAndUpdate( + id, + { $set: payload }, + { new: true } + ) + .lean() + .exec(); + return updated ? toDto(updated) : null; +} + +export async function deleteChannel(id: string): Promise { + 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 }; +} diff --git a/apps/backend/src/modules/notifications/notifications.controller.ts b/apps/backend/src/modules/notifications/notifications.controller.ts index 0627e7f..f28ac5b 100644 --- a/apps/backend/src/modules/notifications/notifications.controller.ts +++ b/apps/backend/src/modules/notifications/notifications.controller.ts @@ -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(); +}; diff --git a/apps/backend/src/modules/notifications/notifications.router.ts b/apps/backend/src/modules/notifications/notifications.router.ts index 77363e9..befa9c1 100644 --- a/apps/backend/src/modules/notifications/notifications.router.ts +++ b/apps/backend/src/modules/notifications/notifications.router.ts @@ -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; diff --git a/apps/backend/src/modules/transactions/notification.parser.ts b/apps/backend/src/modules/transactions/notification.parser.ts index d44a5cb..c8fef22 100644 --- a/apps/backend/src/modules/transactions/notification.parser.ts +++ b/apps/backend/src/modules/transactions/notification.parser.ts @@ -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'; diff --git a/apps/backend/src/modules/transactions/transactions.service.ts b/apps/backend/src/modules/transactions/transactions.service.ts index 95eab5b..37f0314 100644 --- a/apps/backend/src/modules/transactions/transactions.service.ts +++ b/apps/backend/src/modules/transactions/transactions.service.ts @@ -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() .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 { - const result = await TransactionModel.findOneAndDelete({ _id: id, userId }).exec(); + const result = await TransactionModel.findOneAndDelete({ _id: id, userId }) + .lean() + .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 { 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() + .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 } }; diff --git a/apps/frontend/android/app/src/main/java/com/bill/ai/MainActivity.java b/apps/frontend/android/app/src/main/java/com/bill/ai/MainActivity.java index 007cfde..e0db589 100644 --- a/apps/frontend/android/app/src/main/java/com/bill/ai/MainActivity.java +++ b/apps/frontend/android/app/src/main/java/com/bill/ai/MainActivity.java @@ -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); } } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 4c6b0a4..b1ea8cd 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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", diff --git a/apps/frontend/src/components/budgets/BudgetCard.vue b/apps/frontend/src/components/budgets/BudgetCard.vue index 3ea3164..052b92d 100644 --- a/apps/frontend/src/components/budgets/BudgetCard.vue +++ b/apps/frontend/src/components/budgets/BudgetCard.vue @@ -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 '消耗平衡'; +}); diff --git a/apps/frontend/src/composables/useNotificationChannels.ts b/apps/frontend/src/composables/useNotificationChannels.ts new file mode 100644 index 0000000..103d943 --- /dev/null +++ b/apps/frontend/src/composables/useNotificationChannels.ts @@ -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({ + 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 }) => { + 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 }); + } + }); +} diff --git a/apps/frontend/src/composables/useTransactions.ts b/apps/frontend/src/composables/useTransactions.ts index c9498ee..9250d22 100644 --- a/apps/frontend/src/composables/useTransactions.ts +++ b/apps/frontend/src/composables/useTransactions.ts @@ -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({ 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(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(queryKey, (existing = []) => [optimistic, ...existing]); queryClient.invalidateQueries({ queryKey }); } }); diff --git a/apps/frontend/src/features/dashboard/pages/DashboardPage.vue b/apps/frontend/src/features/dashboard/pages/DashboardPage.vue index a507a1a..b11767d 100644 --- a/apps/frontend/src/features/dashboard/pages/DashboardPage.vue +++ b/apps/frontend/src/features/dashboard/pages/DashboardPage.vue @@ -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) + ) +);