feat:新增分类支持
This commit is contained in:
56
apps/frontend/src/composables/useNotificationRules.ts
Normal file
56
apps/frontend/src/composables/useNotificationRules.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
17
apps/frontend/src/lib/categories.ts
Normal file
17
apps/frontend/src/lib/categories.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const PRESET_CATEGORIES = [
|
||||
'餐饮',
|
||||
'交通',
|
||||
'购物',
|
||||
'生活缴费',
|
||||
'医疗',
|
||||
'教育',
|
||||
'娱乐',
|
||||
'转账',
|
||||
'退款',
|
||||
'理财',
|
||||
'公益',
|
||||
'收入',
|
||||
'其他支出',
|
||||
'未分类'
|
||||
];
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user