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)) {
|
if (!ai || (!ai.category && !ai.type && !ai.amount)) {
|
||||||
return res.status(200).json({ data: txn, message: 'No AI classification available' });
|
return res.status(200).json({ data: txn, message: 'No AI classification available' });
|
||||||
}
|
}
|
||||||
const updated = await updateTransaction(id, {
|
const updated = await updateTransaction(
|
||||||
|
id,
|
||||||
|
{
|
||||||
category: ai.category ?? txn.category,
|
category: ai.category ?? txn.category,
|
||||||
type: (ai.type as any) ?? txn.type,
|
type: (ai.type as any) ?? txn.type,
|
||||||
amount: ai.amount ?? txn.amount
|
amount: ai.amount ?? txn.amount,
|
||||||
} as UpdateTransactionInput, req.user.id);
|
metadata: { ...(txn.metadata as any), classification: { source: 'ai', confidence: ai.confidence } }
|
||||||
|
} as UpdateTransactionInput,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
return res.json({ data: updated });
|
return res.json({ data: updated });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -190,11 +190,13 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
|
|||||||
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', mergedTemplate);
|
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', mergedTemplate);
|
||||||
|
|
||||||
// Optionally enhance with AI classification
|
// Optionally enhance with AI classification
|
||||||
|
let aiUsed: { confidence?: number } | null = null;
|
||||||
if (env.AI_ENABLE_CLASSIFICATION && !parsed.category) {
|
if (env.AI_ENABLE_CLASSIFICATION && !parsed.category) {
|
||||||
const ai = await aiClassifyByText(payload.title ?? '', payload.body ?? '');
|
const ai = await aiClassifyByText(payload.title ?? '', payload.body ?? '');
|
||||||
if (ai?.category) parsed.category = ai.category;
|
if (ai?.category) parsed.category = ai.category;
|
||||||
if (ai?.type) parsed.type = ai.type;
|
if (ai?.type) parsed.type = ai.type;
|
||||||
if (ai?.amount && (!parsed.amount || parsed.amount === 0)) parsed.amount = ai.amount;
|
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()}`;
|
const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`;
|
||||||
@@ -209,6 +211,18 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
|
|||||||
return toDto(existing);
|
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 = {
|
const transactionPayload: CreateTransactionInput = {
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
amount: parsed.amount ?? 0,
|
amount: parsed.amount ?? 0,
|
||||||
@@ -219,13 +233,7 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
notes: parsed.notes ?? payload.body,
|
notes: parsed.notes ?? payload.body,
|
||||||
metadata: {
|
metadata: baseMetadata
|
||||||
packageName: payload.packageName,
|
|
||||||
rawBody: payload.body,
|
|
||||||
parser: parsed,
|
|
||||||
channelId: channel._id.toString(),
|
|
||||||
notificationHash
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const userId = await getDefaultUserId();
|
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 budgetsRouter from '../modules/budgets/budgets.router.js';
|
||||||
import notificationsRouter from '../modules/notifications/notifications.router.js';
|
import notificationsRouter from '../modules/notifications/notifications.router.js';
|
||||||
import notificationRulesRouter from '../modules/notifications/notification-rules.router.js';
|
import notificationRulesRouter from '../modules/notifications/notification-rules.router.js';
|
||||||
|
import categoriesRouter from '../modules/categories/categories.router.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -18,5 +19,6 @@ router.use('/analysis', analysisRouter);
|
|||||||
router.use('/budgets', budgetsRouter);
|
router.use('/budgets', budgetsRouter);
|
||||||
router.use('/notifications', notificationsRouter);
|
router.use('/notifications', notificationsRouter);
|
||||||
router.use('/notification-rules', notificationRulesRouter);
|
router.use('/notification-rules', notificationRulesRouter);
|
||||||
|
router.use('/categories', categoriesRouter);
|
||||||
|
|
||||||
export { router };
|
export { router };
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const statusColor = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const packageName = computed(() => props.transaction.metadata?.packageName as string | undefined);
|
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(() => {
|
const sourceLabel = computed(() => {
|
||||||
switch (props.transaction.source) {
|
switch (props.transaction.source) {
|
||||||
@@ -70,6 +72,10 @@ const sourceLabel = computed(() => {
|
|||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-400">
|
||||||
{{ new Date(transaction.occurredAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) }}
|
{{ new Date(transaction.occurredAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) }}
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||||
import { useTransactionQuery } from '../../../composables/useTransaction';
|
import { useTransactionQuery } from '../../../composables/useTransaction';
|
||||||
import { useUpdateTransactionMutation } from '../../../composables/useTransactions';
|
import { useUpdateTransactionMutation } from '../../../composables/useTransactions';
|
||||||
import { PRESET_CATEGORIES } from '../../../lib/categories';
|
import { useCategoriesQuery } from '../../../composables/useCategories';
|
||||||
import { apiClient } from '../../../lib/api/client';
|
import { apiClient } from '../../../lib/api/client';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -19,6 +19,7 @@ const goBack = () => router.back();
|
|||||||
const updateMutation = useUpdateTransactionMutation();
|
const updateMutation = useUpdateTransactionMutation();
|
||||||
const editing = reactive({ category: ref<string>('') });
|
const editing = reactive({ category: ref<string>('') });
|
||||||
const isNotification = computed(() => txn.value?.source === 'notification');
|
const isNotification = computed(() => txn.value?.source === 'notification');
|
||||||
|
const categoriesQuery = useCategoriesQuery();
|
||||||
|
|
||||||
// 初始化默认分类
|
// 初始化默认分类
|
||||||
editing.category = txn.value?.category ?? '';
|
editing.category = txn.value?.category ?? '';
|
||||||
@@ -132,7 +133,7 @@ const aiSuggestRule = async () => {
|
|||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<label class="text-sm text-gray-600 shrink-0">分类</label>
|
<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">
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useDeleteTransactionMutation
|
useDeleteTransactionMutation
|
||||||
} from '../../../composables/useTransactions';
|
} from '../../../composables/useTransactions';
|
||||||
import type { TransactionPayload } from '../../../types/transaction';
|
import type { TransactionPayload } from '../../../types/transaction';
|
||||||
|
import { useCategoriesQuery } from '../../../composables/useCategories';
|
||||||
|
|
||||||
type FilterOption = 'all' | 'expense' | 'income';
|
type FilterOption = 'all' | 'expense' | 'income';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ const form = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const transactionsQuery = useTransactionsQuery();
|
const transactionsQuery = useTransactionsQuery();
|
||||||
|
const categoriesQuery = useCategoriesQuery();
|
||||||
const createTransaction = useCreateTransactionMutation();
|
const createTransaction = useCreateTransactionMutation();
|
||||||
const deleteTransaction = useDeleteTransactionMutation();
|
const deleteTransaction = useDeleteTransactionMutation();
|
||||||
const transactions = computed(() => transactionsQuery.data.value ?? []);
|
const transactions = computed(() => transactionsQuery.data.value ?? []);
|
||||||
@@ -178,20 +180,7 @@ const refreshTransactions = async () => {
|
|||||||
<label class="text-sm text-gray-600">分类筛选</label>
|
<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">
|
<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="all">全部分类</option>
|
||||||
<option value="餐饮">餐饮</option>
|
<option v-for="cat in categoriesQuery.data.value" :key="cat.id" :value="cat.name">{{ cat.name }}</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>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user