feat:分类改为数据库存储
This commit is contained in:
16
apps/backend/src/models/category.model.ts
Normal file
16
apps/backend/src/models/category.model.ts
Normal 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);
|
||||
|
||||
36
apps/backend/src/modules/categories/categories.controller.ts
Normal file
36
apps/backend/src/modules/categories/categories.controller.ts
Normal 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();
|
||||
}
|
||||
|
||||
25
apps/backend/src/modules/categories/categories.router.ts
Normal file
25
apps/backend/src/modules/categories/categories.router.ts
Normal 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;
|
||||
|
||||
85
apps/backend/src/modules/categories/categories.service.ts
Normal file
85
apps/backend/src/modules/categories/categories.service.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
apps/frontend/src/composables/useCategories.ts
Normal file
58
apps/frontend/src/composables/useCategories.ts
Normal 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 })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user