feat:新增分类支持

This commit is contained in:
2025-11-13 14:35:27 +08:00
parent 53f5cf479d
commit bce8bdf9ef
8 changed files with 417 additions and 4 deletions

View File

@@ -0,0 +1,56 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { apiClient } from '../lib/api/client';
import type { NotificationRule } from '../types/notification';
const queryKey = ['notification-rules'];
export function useNotificationRulesQuery() {
return useQuery<NotificationRule[]>({
queryKey,
queryFn: async () => {
const { data } = await apiClient.get('/notification-rules');
return data.data as NotificationRule[];
},
initialData: []
});
}
export function useCreateNotificationRuleMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: Partial<NotificationRule>) => {
const { data } = await apiClient.post('/notification-rules', payload);
return data.data as NotificationRule;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
}
});
}
export function useUpdateNotificationRuleMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, payload }: { id: string; payload: Partial<NotificationRule> }) => {
const { data } = await apiClient.patch(`/notification-rules/${id}`, payload);
return data.data as NotificationRule;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
}
});
}
export function useDeleteNotificationRuleMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await apiClient.delete(`/notification-rules/${id}`);
return id;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
}
});
}

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import LucideIcon from '../../../components/common/LucideIcon.vue';
import {
useNotificationRulesQuery,
useCreateNotificationRuleMutation,
useUpdateNotificationRuleMutation,
useDeleteNotificationRuleMutation
} from '../../../composables/useNotificationRules';
import type { NotificationRule } from '../../../types/notification';
const router = useRouter();
const rulesQuery = useNotificationRulesQuery();
const rules = computed(() => rulesQuery.data.value ?? []);
const createRule = useCreateNotificationRuleMutation();
const updateRule = useUpdateNotificationRuleMutation();
const deleteRule = useDeleteNotificationRuleMutation();
const sheetOpen = ref(false);
const editingId = ref<string | null>(null);
const form = reactive<Partial<NotificationRule>>({
name: '',
enabled: true,
priority: 50,
packageName: '',
keywords: [],
pattern: '',
action: 'record',
category: '',
type: undefined,
amountPattern: ''
});
const isSaving = computed(() => createRule.isPending.value || updateRule.isPending.value);
const openCreate = () => {
editingId.value = null;
Object.assign(form, {
name: '',
enabled: true,
priority: 50,
packageName: '',
keywords: [],
pattern: '',
action: 'record',
category: '',
type: undefined,
amountPattern: ''
});
sheetOpen.value = true;
};
const openEdit = (rule: NotificationRule) => {
editingId.value = rule.id;
Object.assign(form, {
name: rule.name,
enabled: rule.enabled,
priority: rule.priority,
packageName: rule.packageName ?? '',
keywords: rule.keywords ?? [],
pattern: rule.pattern ?? '',
action: rule.action,
category: rule.category ?? '',
type: rule.type,
amountPattern: rule.amountPattern ?? ''
});
sheetOpen.value = true;
};
const parseKeywords = (input: string) => input.split(',').map((s) => s.trim()).filter(Boolean);
const joinKeywords = (arr?: string[]) => (arr && arr.length ? arr.join(',') : '');
const save = async () => {
const payload: Partial<NotificationRule> = {
name: form.name?.trim(),
enabled: form.enabled ?? true,
priority: Number(form.priority ?? 50),
packageName: form.packageName?.trim() || undefined,
keywords: form.keywords ?? [],
pattern: form.pattern?.trim() || undefined,
action: form.action ?? 'record',
category: form.category?.trim() || undefined,
type: form.type,
amountPattern: form.amountPattern?.trim() || undefined
};
if (!payload.name) return;
if (editingId.value) {
await updateRule.mutateAsync({ id: editingId.value, payload });
} else {
await createRule.mutateAsync(payload);
}
sheetOpen.value = false;
};
const toggleEnabled = async (rule: NotificationRule) => {
await updateRule.mutateAsync({ id: rule.id, payload: { enabled: !rule.enabled } });
};
const remove = async (rule: NotificationRule) => {
const ok = window.confirm(`确认删除规则「${rule.name}」吗?`);
if (!ok) return;
await deleteRule.mutateAsync(rule.id);
};
const goBack = () => router.back();
</script>
<template>
<div class="p-6 pt-10 pb-24 space-y-6">
<header class="flex items-center justify-between">
<button class="text-gray-600" @click="goBack">
<LucideIcon name="arrow-left" :size="22" />
</button>
<h1 class="text-xl font-semibold text-gray-900">通知规则管理</h1>
<button class="text-indigo-600 font-medium" @click="openCreate">
新增
</button>
</header>
<div class="space-y-3" v-if="rulesQuery.isFetching.value">
<div class="text-sm text-gray-400 text-center py-8">加载中...</div>
</div>
<div v-else class="space-y-4">
<div v-if="rules.length === 0" class="bg-white border border-dashed border-gray-200 rounded-2xl py-12 text-center text-sm text-gray-400">
暂无规则点击右上角新增
</div>
<div v-else class="space-y-3">
<div
v-for="rule in rules"
:key="rule.id"
class="bg-white border border-gray-100 rounded-2xl p-4 space-y-2"
>
<div class="flex items-start justify-between">
<div class="min-w-0">
<p class="font-semibold text-gray-900 truncate">{{ rule.name }}</p>
<p class="text-xs text-gray-500 truncate">
优先级 {{ rule.priority }} · {{ rule.action === 'ignore' ? '黑名单' : '记录' }}
</p>
<p v-if="rule.packageName" class="text-xs text-gray-400 truncate">包名{{ rule.packageName }}</p>
<p v-if="rule.keywords?.length" class="text-xs text-gray-400 truncate">关键词{{ rule.keywords.join(', ') }}</p>
<p v-if="rule.pattern" class="text-xs text-gray-400 truncate">正则/{{ rule.pattern }}/i</p>
<p v-if="rule.category || rule.type" class="text-xs text-gray-400 truncate">
覆盖{{ rule.category ?? '未指定分类' }} · {{ rule.type ?? '未指定类型' }}
</p>
<p v-if="rule.amountPattern" class="text-xs text-gray-400 truncate">金额正则/{{ rule.amountPattern }}/</p>
<p v-if="rule.matchCount" class="text-[10px] text-gray-400">已匹配 {{ rule.matchCount }} </p>
</div>
<div class="flex items-center space-x-2">
<button class="text-gray-500 hover:text-indigo-600" @click="openEdit(rule)">
<LucideIcon name="pencil" :size="18" />
</button>
<button class="text-gray-500 hover:text-red-600" @click="remove(rule)">
<LucideIcon name="trash-2" :size="18" />
</button>
<button
class="px-3 py-1 text-xs rounded-full"
:class="rule.enabled ? 'bg-emerald-100 text-emerald-600' : 'bg-gray-100 text-gray-500'"
@click="toggleEnabled(rule)"
>
{{ rule.enabled ? '启用' : '停用' }}
</button>
</div>
</div>
</div>
</div>
</div>
<div
v-if="sheetOpen"
class="fixed inset-0 z-50 bg-black/40 flex items-end justify-center"
@click.self="sheetOpen = false"
>
<div class="w-full max-w-sm bg-white rounded-t-3xl p-6 space-y-4 pb-8">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold text-gray-900">{{ editingId ? '编辑规则' : '新增规则' }}</h2>
<button @click="sheetOpen = false">
<LucideIcon name="x" :size="24" />
</button>
</div>
<div class="space-y-4">
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
规则名称
<input v-model="form.name" type="text" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<div class="grid grid-cols-2 gap-4">
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
优先级
<input v-model.number="form.priority" type="number" min="0" max="100" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
操作
<select v-model="(form.action as any)" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="record">记录</option>
<option value="ignore">黑名单</option>
</select>
</label>
</div>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
包名可选
<input v-model="form.packageName" type="text" placeholder="如com.eg.android.AlipayGphone" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
关键词逗号分隔可选
<input :value="joinKeywords(form.keywords)" @input="form.keywords = parseKeywords(($event.target as HTMLInputElement).value)" type="text" placeholder="如:到账, 支出" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
正则表达式可选
<input v-model="form.pattern" type="text" placeholder="如:已支付.*?(\n|\s)*?(\d+\.\d{2})" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<div class="grid grid-cols-2 gap-4">
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
分类覆盖可选
<input v-model="form.category" type="text" placeholder="如:餐饮" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
类型覆盖可选
<select v-model="form.type" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option :value="undefined">未指定</option>
<option value="expense">支出</option>
<option value="income">收入</option>
</select>
</label>
</div>
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
金额正则可选捕获第1组
<input v-model="form.amountPattern" type="text" placeholder="如:(\d+\.\d{2})" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<button
class="w-full bg-indigo-500 text-white py-3 rounded-xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
:disabled="isSaving || !form.name"
@click="save"
>
{{ isSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -301,6 +301,7 @@ const removeChannel = async (channelId: string) => {
<h2 class="text-lg font-semibold text-gray-900">通知监听</h2>
<p class="text-sm text-gray-500">配置原生插件的 Webhook 地址与安全密钥</p>
</div>
<RouterLink to="/settings/rules" class="text-sm text-indigo-500 font-medium">规则管理</RouterLink>
<button
class="text-sm text-indigo-500 font-medium disabled:opacity-60"
:disabled="notificationStatusQuery.isFetching.value"

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, reactive, ref } from 'vue';
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';
const route = useRoute();
const router = useRouter();
@@ -12,6 +14,19 @@ const query = useTransactionQuery(id);
const txn = computed(() => query.data.value);
const goBack = () => router.back();
const updateMutation = useUpdateTransactionMutation();
const editing = reactive({ category: ref<string>('') });
const isNotification = computed(() => txn.value?.source === 'notification');
// 初始化默认分类
editing.category = txn.value?.category ?? '';
const saveCategory = async () => {
if (!txn.value?.id) return;
await updateMutation.mutateAsync({ id: txn.value.id, payload: { category: editing.category || '未分类' } });
await query.refetch();
};
</script>
<template>
@@ -63,6 +78,27 @@ const goBack = () => router.back();
</div>
</div>
<div v-if="isNotification" class="bg-white rounded-2xl p-6 border border-gray-100">
<h3 class="text-base font-semibold text-gray-900 mb-4">手工分类</h3>
<div class="space-y-3">
<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>
</select>
</div>
<div class="text-right">
<button
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-medium hover:bg-indigo-600 disabled:opacity-60"
:disabled="updateMutation.isPending.value"
@click="saveCategory"
>
{{ updateMutation.isPending.value ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
<div v-if="txn.metadata && Object.keys(txn.metadata).length" class="bg-white rounded-2xl p-6 border border-gray-100">
<h3 class="text-base font-semibold text-gray-900 mb-4">关联信息</h3>
<pre class="text-xs text-gray-600 overflow-x-auto">{{ JSON.stringify(txn.metadata, null, 2) }}</pre>
@@ -70,4 +106,3 @@ const goBack = () => router.back();
</div>
</div>
</template>

View File

@@ -12,6 +12,7 @@ import type { TransactionPayload } from '../../../types/transaction';
type FilterOption = 'all' | 'expense' | 'income';
const filter = ref<FilterOption>('all');
const categoryFilter = ref<string>('all');
const showSheet = ref(false);
const form = reactive({
@@ -36,8 +37,14 @@ const filters: Array<{ label: string; value: FilterOption }> = [
const filteredTransactions = computed(() => {
if (!transactions.value.length) return [];
if (filter.value === 'all') return transactions.value;
return transactions.value.filter((txn) => txn.type === filter.value);
let list = transactions.value;
if (filter.value !== 'all') {
list = list.filter((txn) => txn.type === filter.value);
}
if (categoryFilter.value !== 'all') {
list = list.filter((txn) => txn.category === categoryFilter.value);
}
return list;
});
const isSaving = computed(() => createTransaction.isPending.value);
@@ -167,6 +174,27 @@ const refreshTransactions = async () => {
</button>
</div>
<div class="flex items-center space-x-3">
<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>
</select>
</div>
<div class="space-y-4 min-h-[140px]">
<div v-if="isInitialLoading" class="flex justify-center py-16">
<svg class="w-8 h-8 animate-spin text-indigo-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -0,0 +1,17 @@
export const PRESET_CATEGORIES = [
'餐饮',
'交通',
'购物',
'生活缴费',
'医疗',
'教育',
'娱乐',
'转账',
'退款',
'理财',
'公益',
'收入',
'其他支出',
'未分类'
];

View File

@@ -32,6 +32,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../features/settings/pages/SettingsPage.vue'),
meta: { title: '设置', requiresAuth: true }
},
{
path: '/settings/rules',
name: 'notification-rules',
component: () => import('../features/settings/pages/NotificationRulesPage.vue'),
meta: { title: '通知规则', requiresAuth: true }
},
{
path: '/auth',
component: () => import('../features/auth/pages/AuthLayout.vue'),

View File

@@ -11,3 +11,21 @@ export interface NotificationChannel {
amountPattern?: string;
}>;
}
export interface NotificationRule {
id: string;
name: string;
enabled: boolean;
priority: number;
packageName?: string;
keywords?: string[];
pattern?: string;
action: 'record' | 'ignore';
category?: string;
type?: 'income' | 'expense';
amountPattern?: string;
matchCount?: number;
lastMatchedAt?: string;
createdAt?: string;
updatedAt?: string;
}