From 71eba31740627d13eb4d413db8ac24f5f4a28354 Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Thu, 13 Nov 2025 15:54:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=88=86=E7=B1=BB=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/src/models/category.model.ts | 16 ++++ .../categories/categories.controller.ts | 36 ++++++++ .../modules/categories/categories.router.ts | 25 ++++++ .../modules/categories/categories.service.ts | 85 +++++++++++++++++++ .../transactions/transactions.controller.ts | 15 ++-- .../transactions/transactions.service.ts | 22 +++-- apps/backend/src/routes/index.ts | 2 + .../transactions/TransactionItem.vue | 6 ++ .../frontend/src/composables/useCategories.ts | 58 +++++++++++++ .../pages/TransactionDetailPage.vue | 5 +- .../transactions/pages/TransactionsPage.vue | 17 +--- 11 files changed, 259 insertions(+), 28 deletions(-) create mode 100644 apps/backend/src/models/category.model.ts create mode 100644 apps/backend/src/modules/categories/categories.controller.ts create mode 100644 apps/backend/src/modules/categories/categories.router.ts create mode 100644 apps/backend/src/modules/categories/categories.service.ts create mode 100644 apps/frontend/src/composables/useCategories.ts diff --git a/apps/backend/src/models/category.model.ts b/apps/backend/src/models/category.model.ts new file mode 100644 index 0000000..a88bcab --- /dev/null +++ b/apps/backend/src/models/category.model.ts @@ -0,0 +1,16 @@ +import mongoose, { type InferSchemaType } from 'mongoose'; + +const categorySchema = new mongoose.Schema( + { + name: { type: String, required: true, unique: true }, + type: { type: String, enum: ['expense', 'income'], required: false }, + icon: { type: String }, + color: { type: String }, + enabled: { type: Boolean, default: true } + }, + { timestamps: true } +); + +export type CategoryDocument = InferSchemaType; +export const CategoryModel = mongoose.models.Category ?? mongoose.model('Category', categorySchema); + diff --git a/apps/backend/src/modules/categories/categories.controller.ts b/apps/backend/src/modules/categories/categories.controller.ts new file mode 100644 index 0000000..731dffd --- /dev/null +++ b/apps/backend/src/modules/categories/categories.controller.ts @@ -0,0 +1,36 @@ +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import { createCategory, deleteCategory, listCategories, updateCategory } from './categories.service.js'; + +const categorySchema = z.object({ + name: z.string().min(1), + type: z.enum(['expense', 'income']).optional(), + icon: z.string().optional(), + color: z.string().optional(), + enabled: z.boolean().optional() +}); + +export async function listCategoriesHandler(_req: Request, res: Response) { + const data = await listCategories(); + return res.json({ data }); +} + +export async function createCategoryHandler(req: Request, res: Response) { + const payload = categorySchema.parse(req.body); + const data = await createCategory(payload as any); + return res.status(201).json({ data }); +} + +export async function updateCategoryHandler(req: Request, res: Response) { + const payload = categorySchema.partial().parse(req.body); + const data = await updateCategory(req.params.id, payload as any); + if (!data) return res.status(404).json({ message: 'Category not found' }); + return res.json({ data }); +} + +export async function deleteCategoryHandler(req: Request, res: Response) { + const ok = await deleteCategory(req.params.id); + if (!ok) return res.status(404).json({ message: 'Category not found' }); + return res.status(204).send(); +} + diff --git a/apps/backend/src/modules/categories/categories.router.ts b/apps/backend/src/modules/categories/categories.router.ts new file mode 100644 index 0000000..9eb6fd4 --- /dev/null +++ b/apps/backend/src/modules/categories/categories.router.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { authenticate } from '../../middlewares/authenticate.js'; +import { validateRequest } from '../../middlewares/validate-request.js'; +import { z } from 'zod'; +import { + createCategoryHandler, + deleteCategoryHandler, + listCategoriesHandler, + updateCategoryHandler +} from './categories.controller.js'; +import { ensureDefaultCategories } from './categories.service.js'; + +const router = Router(); + +router.use(authenticate); + +router.get('/', listCategoriesHandler); +router.post('/', createCategoryHandler); +router.patch('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }) }), updateCategoryHandler); +router.delete('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }) }), deleteCategoryHandler); + +void ensureDefaultCategories(); + +export default router; + diff --git a/apps/backend/src/modules/categories/categories.service.ts b/apps/backend/src/modules/categories/categories.service.ts new file mode 100644 index 0000000..3f46da5 --- /dev/null +++ b/apps/backend/src/modules/categories/categories.service.ts @@ -0,0 +1,85 @@ +import mongoose from 'mongoose'; +import { CategoryModel, type CategoryDocument } from '../../models/category.model.js'; + +export interface CategoryDto { + id: string; + name: string; + type?: 'expense' | 'income'; + icon?: string; + color?: string; + enabled: boolean; +} + +const toDto = (doc: CategoryDocument & { _id: mongoose.Types.ObjectId }): CategoryDto => ({ + id: doc._id.toString(), + name: doc.name, + type: (doc.type as any) ?? undefined, + icon: doc.icon ?? undefined, + color: doc.color ?? undefined, + enabled: doc.enabled ?? true +}); + +const DEFAULT_CATEGORIES: Array> = [ + { name: '餐饮', type: 'expense' }, + { name: '交通', type: 'expense' }, + { name: '购物', type: 'expense' }, + { name: '生活缴费', type: 'expense' }, + { name: '医疗', type: 'expense' }, + { name: '教育', type: 'expense' }, + { name: '娱乐', type: 'expense' }, + { name: '转账', type: 'expense' }, + { name: '退款', type: 'income' }, + { name: '理财', type: 'expense' }, + { name: '公益', type: 'expense' }, + { name: '收入', type: 'income' }, + { name: '其他支出', type: 'expense' }, + { name: '未分类', type: 'expense' } +]; + +export async function ensureDefaultCategories(): Promise { + const count = await CategoryModel.estimatedDocumentCount().exec(); + if (count > 0) return; + await CategoryModel.insertMany( + DEFAULT_CATEGORIES.map((c) => ({ name: c.name, type: c.type, enabled: true })), + { ordered: false } + ); +} + +export async function listCategories(): Promise { + const docs = await CategoryModel.find({ enabled: true }).sort({ name: 1 }).lean().exec(); + return docs.map((d) => toDto(d as CategoryDocument & { _id: mongoose.Types.ObjectId })); +} + +export async function createCategory(payload: Omit & { enabled?: boolean }): Promise { + const created = await CategoryModel.create({ + name: payload.name, + type: payload.type, + icon: payload.icon, + color: payload.color, + enabled: payload.enabled ?? true + }); + const doc = (await CategoryModel.findById(created._id).lean().exec()) as CategoryDocument & { + _id: mongoose.Types.ObjectId; + }; + return toDto(doc); +} + +export async function updateCategory( + id: string, + payload: Partial> +): Promise { + const doc = await CategoryModel.findByIdAndUpdate( + id, + { $set: payload }, + { new: true } + ) + .lean() + .exec(); + return doc ? toDto(doc) : null; +} + +export async function deleteCategory(id: string): Promise { + const res = await CategoryModel.findByIdAndDelete(id).exec(); + return res !== null; +} + diff --git a/apps/backend/src/modules/transactions/transactions.controller.ts b/apps/backend/src/modules/transactions/transactions.controller.ts index 97e6989..670e571 100644 --- a/apps/backend/src/modules/transactions/transactions.controller.ts +++ b/apps/backend/src/modules/transactions/transactions.controller.ts @@ -119,11 +119,16 @@ export const aiClassifyTransactionHandler = async (req: Request, res: Response) if (!ai || (!ai.category && !ai.type && !ai.amount)) { return res.status(200).json({ data: txn, message: 'No AI classification available' }); } - const updated = await updateTransaction(id, { - category: ai.category ?? txn.category, - type: (ai.type as any) ?? txn.type, - amount: ai.amount ?? txn.amount - } as UpdateTransactionInput, req.user.id); + const updated = await updateTransaction( + id, + { + category: ai.category ?? txn.category, + type: (ai.type as any) ?? txn.type, + amount: ai.amount ?? txn.amount, + metadata: { ...(txn.metadata as any), classification: { source: 'ai', confidence: ai.confidence } } + } as UpdateTransactionInput, + req.user.id + ); return res.json({ data: updated }); }; diff --git a/apps/backend/src/modules/transactions/transactions.service.ts b/apps/backend/src/modules/transactions/transactions.service.ts index 36c43b4..a167037 100644 --- a/apps/backend/src/modules/transactions/transactions.service.ts +++ b/apps/backend/src/modules/transactions/transactions.service.ts @@ -190,11 +190,13 @@ export async function ingestNotification(payload: NotificationPayload): Promise< const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', mergedTemplate); // Optionally enhance with AI classification + let aiUsed: { confidence?: number } | null = null; if (env.AI_ENABLE_CLASSIFICATION && !parsed.category) { const ai = await aiClassifyByText(payload.title ?? '', payload.body ?? ''); if (ai?.category) parsed.category = ai.category; if (ai?.type) parsed.type = ai.type; if (ai?.amount && (!parsed.amount || parsed.amount === 0)) parsed.amount = ai.amount; + if (ai) aiUsed = { confidence: ai.confidence }; } const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`; @@ -209,6 +211,18 @@ export async function ingestNotification(payload: NotificationPayload): Promise< return toDto(existing); } + const baseMetadata = { + packageName: payload.packageName, + rawBody: payload.body, + parser: parsed, + channelId: channel._id.toString(), + notificationHash + } as Record; + + if (aiUsed) { + (baseMetadata as any).classification = { source: 'ai', confidence: aiUsed.confidence }; + } + const transactionPayload: CreateTransactionInput = { title: payload.title, amount: parsed.amount ?? 0, @@ -219,13 +233,7 @@ export async function ingestNotification(payload: NotificationPayload): Promise< status: 'pending', occurredAt: now, notes: parsed.notes ?? payload.body, - metadata: { - packageName: payload.packageName, - rawBody: payload.body, - parser: parsed, - channelId: channel._id.toString(), - notificationHash - } + metadata: baseMetadata }; const userId = await getDefaultUserId(); diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts index c539488..6ad2a8f 100644 --- a/apps/backend/src/routes/index.ts +++ b/apps/backend/src/routes/index.ts @@ -5,6 +5,7 @@ import analysisRouter from '../modules/analysis/analysis.router.js'; import budgetsRouter from '../modules/budgets/budgets.router.js'; import notificationsRouter from '../modules/notifications/notifications.router.js'; import notificationRulesRouter from '../modules/notifications/notification-rules.router.js'; +import categoriesRouter from '../modules/categories/categories.router.js'; const router = Router(); @@ -18,5 +19,6 @@ router.use('/analysis', analysisRouter); router.use('/budgets', budgetsRouter); router.use('/notifications', notificationsRouter); router.use('/notification-rules', notificationRulesRouter); +router.use('/categories', categoriesRouter); export { router }; diff --git a/apps/frontend/src/components/transactions/TransactionItem.vue b/apps/frontend/src/components/transactions/TransactionItem.vue index 0f130ae..6bc9889 100644 --- a/apps/frontend/src/components/transactions/TransactionItem.vue +++ b/apps/frontend/src/components/transactions/TransactionItem.vue @@ -34,6 +34,8 @@ const statusColor = computed(() => { }); const packageName = computed(() => props.transaction.metadata?.packageName as string | undefined); +const classification = computed(() => props.transaction.metadata?.classification as any | undefined); +const hasRuleId = computed(() => Boolean((props.transaction.metadata as any)?.classification?.ruleId)); const sourceLabel = computed(() => { switch (props.transaction.source) { @@ -70,6 +72,10 @@ const sourceLabel = computed(() => {

{{ new Date(transaction.occurredAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) }}

+
+ AI + 规则 +
diff --git a/apps/frontend/src/composables/useCategories.ts b/apps/frontend/src/composables/useCategories.ts new file mode 100644 index 0000000..228e0d0 --- /dev/null +++ b/apps/frontend/src/composables/useCategories.ts @@ -0,0 +1,58 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'; +import { apiClient } from '../lib/api/client'; + +export interface Category { + id: string; + name: string; + type?: 'expense' | 'income'; + icon?: string; + color?: string; + enabled: boolean; +} + +const queryKey = ['categories']; + +export function useCategoriesQuery() { + return useQuery({ + queryKey, + queryFn: async () => { + const { data } = await apiClient.get('/categories'); + return data.data as Category[]; + }, + initialData: [] + }); +} + +export function useCreateCategoryMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (payload: Partial) => { + const { data } = await apiClient.post('/categories', payload); + return data.data as Category; + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey }) + }); +} + +export function useUpdateCategoryMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: Partial }) => { + const { data } = await apiClient.patch(`/categories/${id}`, payload); + return data.data as Category; + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey }) + }); +} + +export function useDeleteCategoryMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/categories/${id}`); + return id; + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey }) + }); +} + diff --git a/apps/frontend/src/features/transactions/pages/TransactionDetailPage.vue b/apps/frontend/src/features/transactions/pages/TransactionDetailPage.vue index f0b1763..47cc60d 100644 --- a/apps/frontend/src/features/transactions/pages/TransactionDetailPage.vue +++ b/apps/frontend/src/features/transactions/pages/TransactionDetailPage.vue @@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'; import LucideIcon from '../../../components/common/LucideIcon.vue'; import { useTransactionQuery } from '../../../composables/useTransaction'; import { useUpdateTransactionMutation } from '../../../composables/useTransactions'; -import { PRESET_CATEGORIES } from '../../../lib/categories'; +import { useCategoriesQuery } from '../../../composables/useCategories'; import { apiClient } from '../../../lib/api/client'; const route = useRoute(); @@ -19,6 +19,7 @@ const goBack = () => router.back(); const updateMutation = useUpdateTransactionMutation(); const editing = reactive({ category: ref('') }); const isNotification = computed(() => txn.value?.source === 'notification'); +const categoriesQuery = useCategoriesQuery(); // 初始化默认分类 editing.category = txn.value?.category ?? ''; @@ -132,7 +133,7 @@ const aiSuggestRule = async () => {
diff --git a/apps/frontend/src/features/transactions/pages/TransactionsPage.vue b/apps/frontend/src/features/transactions/pages/TransactionsPage.vue index 14bad79..eda8d6f 100644 --- a/apps/frontend/src/features/transactions/pages/TransactionsPage.vue +++ b/apps/frontend/src/features/transactions/pages/TransactionsPage.vue @@ -8,6 +8,7 @@ import { useDeleteTransactionMutation } from '../../../composables/useTransactions'; import type { TransactionPayload } from '../../../types/transaction'; +import { useCategoriesQuery } from '../../../composables/useCategories'; type FilterOption = 'all' | 'expense' | 'income'; @@ -25,6 +26,7 @@ const form = reactive({ }); const transactionsQuery = useTransactionsQuery(); +const categoriesQuery = useCategoriesQuery(); const createTransaction = useCreateTransactionMutation(); const deleteTransaction = useDeleteTransactionMutation(); const transactions = computed(() => transactionsQuery.data.value ?? []); @@ -178,20 +180,7 @@ const refreshTransactions = async () => {