feat:分类改为数据库存储

This commit is contained in:
2025-11-13 15:54:22 +08:00
parent 1f21fbfe16
commit 71eba31740
11 changed files with 259 additions and 28 deletions

View File

@@ -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<typeof categorySchema>;
export const CategoryModel = mongoose.models.Category ?? mongoose.model('Category', categorySchema);

View File

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

View File

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

View File

@@ -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<Pick<CategoryDto, 'name' | 'type'>> = [
{ 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<void> {
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<CategoryDto[]> {
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<CategoryDto, 'id' | 'enabled'> & { enabled?: boolean }): Promise<CategoryDto> {
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<Omit<CategoryDto, 'id'>>
): Promise<CategoryDto | null> {
const doc = await CategoryModel.findByIdAndUpdate(
id,
{ $set: payload },
{ new: true }
)
.lean<CategoryDocument & { _id: mongoose.Types.ObjectId }>()
.exec();
return doc ? toDto(doc) : null;
}
export async function deleteCategory(id: string): Promise<boolean> {
const res = await CategoryModel.findByIdAndDelete(id).exec();
return res !== null;
}

View File

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

View File

@@ -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<string, unknown>;
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();

View File

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

View File

@@ -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(() => {
<p class="text-sm text-gray-400">
{{ new Date(transaction.occurredAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) }}
</p>
<div class="mt-1 flex justify-end space-x-2">
<span v-if="classification?.source === 'ai'" class="text-[10px] px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700">AI</span>
<span v-else-if="hasRuleId" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">规则</span>
</div>
</div>
</div>
</template>

View File

@@ -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<Category[]>({
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<Category>) => {
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<Category> }) => {
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 })
});
}

View File

@@ -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<string>('') });
const isNotification = computed(() => txn.value?.source === 'notification');
const categoriesQuery = useCategoriesQuery();
// 初始化默认分类
editing.category = txn.value?.category ?? '';
@@ -132,7 +133,7 @@ const aiSuggestRule = async () => {
<div class="flex items-center space-x-3">
<label class="text-sm text-gray-600 shrink-0">分类</label>
<select v-model="editing.category" class="flex-1 px-3 py-2 border border-gray-200 rounded-xl text-sm">
<option v-for="cat in PRESET_CATEGORIES" :key="cat" :value="cat">{{ cat }}</option>
<option v-for="cat in categoriesQuery.data.value" :key="cat.id" :value="cat.name">{{ cat.name }}</option>
</select>
</div>
<div class="text-right">

View File

@@ -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 () => {
<label class="text-sm text-gray-600">分类筛选</label>
<select v-model="categoryFilter" class="flex-1 px-3 py-2 border border-gray-200 rounded-xl text-sm">
<option value="all">全部分类</option>
<option value="餐饮">餐饮</option>
<option value="交通">交通</option>
<option value="购物">购物</option>
<option value="生活缴费">生活缴费</option>
<option value="医疗">医疗</option>
<option value="教育">教育</option>
<option value="娱乐">娱乐</option>
<option value="转账">转账</option>
<option value="退款">退款</option>
<option value="理财">理财</option>
<option value="公益">公益</option>
<option value="收入">收入</option>
<option value="其他支出">其他支出</option>
<option value="未分类">未分类</option>
<option v-for="cat in categoriesQuery.data.value" :key="cat.id" :value="cat.name">{{ cat.name }}</option>
</select>
</div>