feat:添加AI功能
This commit is contained in:
@@ -11,3 +11,12 @@ NOTIFICATION_ALLOWED_PACKAGES=com.eg.android.AlipayGphone,com.tencent.mm
|
|||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
OCR_PROJECT_ID=
|
OCR_PROJECT_ID=
|
||||||
FILE_STORAGE_BUCKET=
|
FILE_STORAGE_BUCKET=
|
||||||
|
|
||||||
|
# DeepSeek AI (OpenAI compatible)
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# Feature flags
|
||||||
|
AI_ENABLE_CLASSIFICATION=false
|
||||||
|
AI_AUTO_LEARN_RULES=false
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ NOTIFICATION_WEBHOOK_SECRET=bf921ce9ac433ba2fdfccbccb50e7d805ac89f166962e4b8437a
|
|||||||
NOTIFICATION_ALLOWED_PACKAGES=com.eg.android.AlipayGphone,com.tencent.mm
|
NOTIFICATION_ALLOWED_PACKAGES=com.eg.android.AlipayGphone,com.tencent.mm
|
||||||
|
|
||||||
# Optional integrations (leave blank if unused)
|
# Optional integrations (leave blank if unused)
|
||||||
GEMINI_API_KEY=
|
|
||||||
OCR_PROJECT_ID=
|
# DeepSeek AI (OpenAI compatible)
|
||||||
FILE_STORAGE_BUCKET=
|
DEEPSEEK_API_KEY=sk-aa152188952b4708a087693af9082721
|
||||||
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
|
# Feature flags
|
||||||
|
AI_ENABLE_CLASSIFICATION=false
|
||||||
|
AI_AUTO_LEARN_RULES=false
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"openai": "^4.58.1",
|
||||||
"mongoose": "^7.6.0",
|
"mongoose": "^7.6.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const envSchema = z
|
|||||||
})
|
})
|
||||||
.transform((values) => ({
|
.transform((values) => ({
|
||||||
...values,
|
...values,
|
||||||
|
DEEPSEEK_API_KEY: (values as any).DEEPSEEK_API_KEY as string | undefined,
|
||||||
|
DEEPSEEK_BASE_URL: ((values as any).DEEPSEEK_BASE_URL as string | undefined) ?? 'https://api.deepseek.com',
|
||||||
|
DEEPSEEK_MODEL: ((values as any).DEEPSEEK_MODEL as string | undefined) ?? 'deepseek-chat',
|
||||||
|
AI_ENABLE_CLASSIFICATION: ((values as any).AI_ENABLE_CLASSIFICATION as string | undefined) === 'true',
|
||||||
|
AI_AUTO_LEARN_RULES: ((values as any).AI_AUTO_LEARN_RULES as string | undefined) === 'true',
|
||||||
isProduction: values.NODE_ENV === 'production',
|
isProduction: values.NODE_ENV === 'production',
|
||||||
notificationPackageWhitelist: values.NOTIFICATION_ALLOWED_PACKAGES
|
notificationPackageWhitelist: values.NOTIFICATION_ALLOWED_PACKAGES
|
||||||
? values.NOTIFICATION_ALLOWED_PACKAGES.split(',').map((pkg) => pkg.trim()).filter(Boolean)
|
? values.NOTIFICATION_ALLOWED_PACKAGES.split(',').map((pkg) => pkg.trim()).filter(Boolean)
|
||||||
|
|||||||
@@ -145,4 +145,3 @@ export async function applyNotificationRules(input: {
|
|||||||
|
|
||||||
return { ignore: false, overrides: {} };
|
return { ignore: false, overrides: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
ingestNotification
|
ingestNotification
|
||||||
} from './transactions.service.js';
|
} from './transactions.service.js';
|
||||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||||
|
import { aiClassifyByText, aiSuggestRuleForNotification } from '../../services/ai.service.js';
|
||||||
|
import { createRule } from '../notifications/notification-rules.service.js';
|
||||||
|
import { TransactionModel } from '../../models/transaction.model.js';
|
||||||
|
|
||||||
export const listTransactionsHandler = async (req: Request, res: Response) => {
|
export const listTransactionsHandler = async (req: Request, res: Response) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
@@ -104,3 +107,91 @@ export const createTransactionFromNotificationHandler = async (req: Request, res
|
|||||||
data: transaction
|
data: transaction
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const aiClassifyTransactionHandler = async (req: Request, res: Response) => {
|
||||||
|
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
|
||||||
|
const id = req.params.id;
|
||||||
|
const txn = await getTransactionById(id, req.user.id);
|
||||||
|
if (!txn) return res.status(404).json({ message: 'Transaction not found' });
|
||||||
|
if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' });
|
||||||
|
const bodyText = (txn.notes as string | undefined) ?? (txn.metadata as any)?.rawBody ?? '';
|
||||||
|
const ai = await aiClassifyByText(txn.title, bodyText);
|
||||||
|
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);
|
||||||
|
return res.json({ data: updated });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBlacklistRuleFromTransactionHandler = async (req: Request, res: Response) => {
|
||||||
|
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
|
||||||
|
const id = req.params.id;
|
||||||
|
const txn = await getTransactionById(id, req.user.id);
|
||||||
|
if (!txn) return res.status(404).json({ message: 'Transaction not found' });
|
||||||
|
if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' });
|
||||||
|
const pkg = (txn.metadata as any)?.packageName as string | undefined;
|
||||||
|
const title = txn.title;
|
||||||
|
if (!pkg || !title) return res.status(400).json({ message: 'Missing notification metadata' });
|
||||||
|
const rule = await createRule({
|
||||||
|
name: `黑名单-${pkg}-${title.substring(0, 10)}`,
|
||||||
|
enabled: true,
|
||||||
|
priority: 95,
|
||||||
|
packageName: pkg,
|
||||||
|
keywords: [title],
|
||||||
|
action: 'ignore'
|
||||||
|
} as any);
|
||||||
|
return res.status(201).json({ data: rule });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRuleFromTransactionHandler = async (req: Request, res: Response) => {
|
||||||
|
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
|
||||||
|
const id = req.params.id;
|
||||||
|
const txn = await getTransactionById(id, req.user.id);
|
||||||
|
if (!txn) return res.status(404).json({ message: 'Transaction not found' });
|
||||||
|
if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' });
|
||||||
|
const pkg = (txn.metadata as any)?.packageName as string | undefined;
|
||||||
|
const title = txn.title;
|
||||||
|
if (!pkg || !title) return res.status(400).json({ message: 'Missing notification metadata' });
|
||||||
|
const rule = await createRule({
|
||||||
|
name: `规则-${pkg}-${title.substring(0, 10)}`,
|
||||||
|
enabled: true,
|
||||||
|
priority: 80,
|
||||||
|
packageName: pkg,
|
||||||
|
keywords: [title],
|
||||||
|
action: 'record',
|
||||||
|
category: (txn as any).category,
|
||||||
|
type: (txn as any).type
|
||||||
|
} as any);
|
||||||
|
return res.status(201).json({ data: rule });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const aiSuggestRuleFromTransactionHandler = async (req: Request, res: Response) => {
|
||||||
|
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
|
||||||
|
const id = req.params.id;
|
||||||
|
const txn = await getTransactionById(id, req.user.id);
|
||||||
|
if (!txn) return res.status(404).json({ message: 'Transaction not found' });
|
||||||
|
if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' });
|
||||||
|
const pkg = (txn.metadata as any)?.packageName as string | undefined;
|
||||||
|
const body = (txn.metadata as any)?.rawBody as string | undefined;
|
||||||
|
const title = txn.title;
|
||||||
|
if (!pkg) return res.status(400).json({ message: 'Missing notification metadata' });
|
||||||
|
const suggestion = await aiSuggestRuleForNotification({ packageName: pkg, title: title ?? '', body: body ?? '' });
|
||||||
|
if (!suggestion) return res.status(200).json({ message: 'No AI suggestion' });
|
||||||
|
const rule = await createRule({
|
||||||
|
name: suggestion.name ?? `AI规则-${pkg}-${Date.now()}`,
|
||||||
|
enabled: suggestion.enabled ?? false,
|
||||||
|
priority: suggestion.priority ?? 60,
|
||||||
|
packageName: pkg,
|
||||||
|
keywords: suggestion.keywords ?? [],
|
||||||
|
pattern: suggestion.pattern ?? undefined,
|
||||||
|
action: suggestion.action ?? 'record',
|
||||||
|
category: suggestion.category ?? undefined,
|
||||||
|
type: suggestion.type ?? undefined,
|
||||||
|
amountPattern: suggestion.amountPattern ?? undefined
|
||||||
|
} as any);
|
||||||
|
return res.status(201).json({ data: rule });
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
getTransactionHandler,
|
getTransactionHandler,
|
||||||
updateTransactionHandler,
|
updateTransactionHandler,
|
||||||
deleteTransactionHandler,
|
deleteTransactionHandler,
|
||||||
createTransactionFromNotificationHandler
|
createTransactionFromNotificationHandler,
|
||||||
|
aiClassifyTransactionHandler,
|
||||||
|
createBlacklistRuleFromTransactionHandler,
|
||||||
|
createRuleFromTransactionHandler,
|
||||||
|
aiSuggestRuleFromTransactionHandler
|
||||||
} from './transactions.controller.js';
|
} from './transactions.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -27,5 +31,9 @@ router.post('/', validateRequest({ body: createTransactionSchema }), createTrans
|
|||||||
router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler);
|
router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler);
|
||||||
router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler);
|
router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler);
|
||||||
router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler);
|
router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler);
|
||||||
|
router.post('/:id/ai-classify', validateRequest({ params: transactionIdSchema }), aiClassifyTransactionHandler);
|
||||||
|
router.post('/:id/rules/blacklist', validateRequest({ params: transactionIdSchema }), createBlacklistRuleFromTransactionHandler);
|
||||||
|
router.post('/:id/rules/from-notification', validateRequest({ params: transactionIdSchema }), createRuleFromTransactionHandler);
|
||||||
|
router.post('/:id/rules/ai-suggest', validateRequest({ params: transactionIdSchema }), aiSuggestRuleFromTransactionHandler);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { getChannelByPackage } from '../notifications/notification-channels.serv
|
|||||||
import { getDefaultUserId } from '../../services/user-context.js';
|
import { getDefaultUserId } from '../../services/user-context.js';
|
||||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||||
import { parseNotificationPayload } from './notification.parser.js';
|
import { parseNotificationPayload } from './notification.parser.js';
|
||||||
import { applyNotificationRules } from '../notifications/notification-rules.service.js';
|
import { applyNotificationRules, createRule } from '../notifications/notification-rules.service.js';
|
||||||
|
import { aiClassifyByText, aiSuggestRuleForNotification } from '../../services/ai.service.js';
|
||||||
|
import { env } from '../../config/env.js';
|
||||||
|
|
||||||
export interface TransactionDto {
|
export interface TransactionDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -187,6 +189,14 @@ 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
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`;
|
const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`;
|
||||||
const notificationHash = createHash('sha256').update(hashSource).digest('hex');
|
const notificationHash = createHash('sha256').update(hashSource).digest('hex');
|
||||||
|
|
||||||
@@ -219,5 +229,36 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const userId = await getDefaultUserId();
|
const userId = await getDefaultUserId();
|
||||||
return createTransaction(transactionPayload, userId);
|
const created = await createTransaction(transactionPayload, userId);
|
||||||
|
|
||||||
|
// Auto-learn rule (as draft) in background
|
||||||
|
if (env.AI_AUTO_LEARN_RULES) {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const suggestion = await aiSuggestRuleForNotification({
|
||||||
|
packageName: payload.packageName,
|
||||||
|
title: payload.title ?? '',
|
||||||
|
body: payload.body ?? ''
|
||||||
|
});
|
||||||
|
if (suggestion && (suggestion.keywords?.length || suggestion.pattern)) {
|
||||||
|
await createRule({
|
||||||
|
name: suggestion.name ?? `AI规则-${payload.packageName}-${Date.now()}`,
|
||||||
|
enabled: suggestion.enabled ?? false,
|
||||||
|
priority: suggestion.priority ?? 60,
|
||||||
|
packageName: payload.packageName,
|
||||||
|
keywords: suggestion.keywords ?? [],
|
||||||
|
pattern: suggestion.pattern ?? undefined,
|
||||||
|
action: suggestion.action ?? 'record',
|
||||||
|
category: suggestion.category ?? undefined,
|
||||||
|
type: suggestion.type ?? undefined,
|
||||||
|
amountPattern: suggestion.amountPattern ?? undefined
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// swallow errors
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|||||||
102
apps/backend/src/services/ai.service.ts
Normal file
102
apps/backend/src/services/ai.service.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import OpenAI from 'openai';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { logger } from '../config/logger.js';
|
||||||
|
|
||||||
|
const ai = env.DEEPSEEK_API_KEY
|
||||||
|
? new OpenAI({ apiKey: env.DEEPSEEK_API_KEY, baseURL: env.DEEPSEEK_BASE_URL })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
export interface AiClassification {
|
||||||
|
category?: string;
|
||||||
|
type?: 'income' | 'expense';
|
||||||
|
amount?: number;
|
||||||
|
confidence?: number; // 0-1
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiRuleSuggestion {
|
||||||
|
enabled?: boolean;
|
||||||
|
action?: 'record' | 'ignore';
|
||||||
|
name?: string;
|
||||||
|
priority?: number;
|
||||||
|
packageName?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
pattern?: string;
|
||||||
|
category?: string;
|
||||||
|
type?: 'income' | 'expense';
|
||||||
|
amountPattern?: string;
|
||||||
|
confidence?: number; // 0-1
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson<T = unknown>(text: string): T | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aiClassifyByText(title: string, body?: string): Promise<AiClassification | null> {
|
||||||
|
if (!ai) return null;
|
||||||
|
const prompt = `你是一个财务助手。请从通知文本中判断账单类型与分类,并尽量提取金额。
|
||||||
|
只返回JSON,不要任何多余解释。例如:{"category":"餐饮","type":"expense","amount":25.5,"confidence":0.8}
|
||||||
|
常见分类:餐饮、交通、购物、生活缴费、医疗、教育、娱乐、转账、退款、理财、公益、收入、其他支出。
|
||||||
|
如果无法判断,保持相应字段为空或省略,confidence 介于 0 到 1。
|
||||||
|
|
||||||
|
标题: ${title}\n正文: ${body ?? ''}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await ai.chat.completions.create({
|
||||||
|
model: env.DEEPSEEK_MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: 'You are a helpful assistant that outputs only JSON.' },
|
||||||
|
{ role: 'user', content: prompt }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const content = res.choices?.[0]?.message?.content ?? '';
|
||||||
|
const parsed = safeJson<AiClassification>(content);
|
||||||
|
return parsed ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'AI classify failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aiSuggestRuleForNotification(input: {
|
||||||
|
packageName: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}): Promise<AiRuleSuggestion | null> {
|
||||||
|
if (!ai) return null;
|
||||||
|
const prompt = `你是一个通知解析助手。根据通知标题与正文,生成一个用于匹配的规则建议,返回纯JSON:
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"action": "record" | "ignore",
|
||||||
|
"name": "简短规则名",
|
||||||
|
"priority": 60,
|
||||||
|
"keywords": ["多个关键词可选"],
|
||||||
|
"pattern": "可选的正则表达式(不要加分隔符)",
|
||||||
|
"category": "可选 分类",
|
||||||
|
"type": "income" | "expense",
|
||||||
|
"amountPattern": "可选,金额捕获组",
|
||||||
|
"confidence": 0.0-1.0
|
||||||
|
}
|
||||||
|
说明:keywords 与 pattern 二选一或都给;pattern 不要包含 /;若该通知应忽略,请 action 设为 "ignore"。
|
||||||
|
标题: ${input.title}\n正文: ${input.body}\n包名: ${input.packageName}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await ai.chat.completions.create({
|
||||||
|
model: env.DEEPSEEK_MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: 'You are a helpful assistant that outputs only JSON.' },
|
||||||
|
{ role: 'user', content: prompt }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const content = res.choices?.[0]?.message?.content ?? '';
|
||||||
|
const parsed = safeJson<AiRuleSuggestion>(content);
|
||||||
|
return parsed ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'AI rule suggestion failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ 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 { PRESET_CATEGORIES } from '../../../lib/categories';
|
||||||
|
import { apiClient } from '../../../lib/api/client';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -27,6 +28,53 @@ const saveCategory = async () => {
|
|||||||
await updateMutation.mutateAsync({ id: txn.value.id, payload: { category: editing.category || '未分类' } });
|
await updateMutation.mutateAsync({ id: txn.value.id, payload: { category: editing.category || '未分类' } });
|
||||||
await query.refetch();
|
await query.refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const feedback = ref<string | null>(null);
|
||||||
|
const show = (msg: string) => {
|
||||||
|
feedback.value = msg;
|
||||||
|
setTimeout(() => (feedback.value = null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useAiClassify = async () => {
|
||||||
|
if (!txn.value?.id) return;
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/transactions/${txn.value.id}/ai-classify`);
|
||||||
|
await query.refetch();
|
||||||
|
show('已使用 AI 分类');
|
||||||
|
} catch (e) {
|
||||||
|
show('AI 分类失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBlacklistRule = async () => {
|
||||||
|
if (!txn.value?.id) return;
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/transactions/${txn.value.id}/rules/blacklist`);
|
||||||
|
show('已加入黑名单规则');
|
||||||
|
} catch (e) {
|
||||||
|
show('创建黑名单失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRuleFromNotification = async () => {
|
||||||
|
if (!txn.value?.id) return;
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/transactions/${txn.value.id}/rules/from-notification`);
|
||||||
|
show('已根据该通知创建规则');
|
||||||
|
} catch (e) {
|
||||||
|
show('创建规则失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiSuggestRule = async () => {
|
||||||
|
if (!txn.value?.id) return;
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/transactions/${txn.value.id}/rules/ai-suggest`);
|
||||||
|
show('已创建 AI 规则草案');
|
||||||
|
} catch (e) {
|
||||||
|
show('AI 规则分析失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -78,7 +126,7 @@ const saveCategory = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isNotification" class="bg-white rounded-2xl p-6 border border-gray-100">
|
<div v-if="isNotification" class="bg-white rounded-2xl p-6 border border-gray-100 space-y-4">
|
||||||
<h3 class="text-base font-semibold text-gray-900 mb-4">手工分类</h3>
|
<h3 class="text-base font-semibold text-gray-900 mb-4">手工分类</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
@@ -97,6 +145,16 @@ const saveCategory = async () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="h-px bg-gray-100"></div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">快捷操作</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button class="px-3 py-2 border border-gray-200 rounded-xl text-sm" @click="useAiClassify">使用 AI 分类</button>
|
||||||
|
<button class="px-3 py-2 border border-gray-200 rounded-xl text-sm" @click="createBlacklistRule">避免此类通知</button>
|
||||||
|
<button class="px-3 py-2 border border-gray-200 rounded-xl text-sm" @click="createRuleFromNotification">使用该通知生成规则</button>
|
||||||
|
<button class="px-3 py-2 border border-gray-200 rounded-xl text-sm" @click="aiSuggestRule">AI 分析规则</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="feedback" class="text-xs text-emerald-600 text-right">{{ feedback }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="txn.metadata && Object.keys(txn.metadata).length" class="bg-white rounded-2xl p-6 border border-gray-100">
|
<div v-if="txn.metadata && Object.keys(txn.metadata).length" class="bg-white rounded-2xl p-6 border border-gray-100">
|
||||||
|
|||||||
86
pnpm-lock.yaml
generated
86
pnpm-lock.yaml
generated
@@ -40,6 +40,9 @@ importers:
|
|||||||
multer:
|
multer:
|
||||||
specifier: ^1.4.5-lts.1
|
specifier: ^1.4.5-lts.1
|
||||||
version: 1.4.5-lts.2
|
version: 1.4.5-lts.2
|
||||||
|
openai:
|
||||||
|
specifier: ^4.58.1
|
||||||
|
version: 4.104.0(zod@3.25.76)
|
||||||
pino:
|
pino:
|
||||||
specifier: ^9.5.0
|
specifier: ^9.5.0
|
||||||
version: 9.14.0
|
version: 9.14.0
|
||||||
@@ -1308,6 +1311,19 @@ packages:
|
|||||||
'@types/express': 4.17.25
|
'@types/express': 4.17.25
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/node-fetch@2.6.13:
|
||||||
|
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.9.2
|
||||||
|
form-data: 4.0.4
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@types/node@18.19.130:
|
||||||
|
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
|
||||||
|
dependencies:
|
||||||
|
undici-types: 5.26.5
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@types/node@20.19.24:
|
/@types/node@20.19.24:
|
||||||
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
|
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1657,7 +1673,6 @@ packages:
|
|||||||
engines: {node: '>=6.5'}
|
engines: {node: '>=6.5'}
|
||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim: 5.0.1
|
event-target-shim: 5.0.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/accepts@1.3.8:
|
/accepts@1.3.8:
|
||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
@@ -1691,6 +1706,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==}
|
resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/agentkeepalive@4.6.0:
|
||||||
|
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||||
|
engines: {node: '>= 8.0.0'}
|
||||||
|
dependencies:
|
||||||
|
humanize-ms: 1.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/aggregate-error@3.1.0:
|
/aggregate-error@3.1.0:
|
||||||
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
|
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2932,7 +2954,6 @@ packages:
|
|||||||
/event-target-shim@5.0.1:
|
/event-target-shim@5.0.1:
|
||||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/events-universal@1.0.1:
|
/events-universal@1.0.1:
|
||||||
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||||
@@ -3145,6 +3166,10 @@ packages:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
|
/form-data-encoder@1.7.2:
|
||||||
|
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/form-data@4.0.4:
|
/form-data@4.0.4:
|
||||||
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3156,6 +3181,14 @@ packages:
|
|||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/formdata-node@4.4.1:
|
||||||
|
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
|
||||||
|
engines: {node: '>= 12.20'}
|
||||||
|
dependencies:
|
||||||
|
node-domexception: 1.0.0
|
||||||
|
web-streams-polyfill: 4.0.0-beta.3
|
||||||
|
dev: false
|
||||||
|
|
||||||
/formidable@3.5.4:
|
/formidable@3.5.4:
|
||||||
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
|
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -3489,6 +3522,12 @@ packages:
|
|||||||
statuses: 2.0.1
|
statuses: 2.0.1
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
/humanize-ms@1.2.1:
|
||||||
|
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
dev: false
|
||||||
|
|
||||||
/iconv-lite@0.4.24:
|
/iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4279,6 +4318,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==}
|
resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/node-domexception@1.0.0:
|
||||||
|
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||||
|
engines: {node: '>=10.5.0'}
|
||||||
|
deprecated: Use your platform's native DOMException instead
|
||||||
|
dev: false
|
||||||
|
|
||||||
/node-fetch@2.7.0:
|
/node-fetch@2.7.0:
|
||||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||||
engines: {node: 4.x || >=6.0.0}
|
engines: {node: 4.x || >=6.0.0}
|
||||||
@@ -4289,7 +4334,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
dev: true
|
|
||||||
|
|
||||||
/node-html-parser@5.4.2:
|
/node-html-parser@5.4.2:
|
||||||
resolution: {integrity: sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw==}
|
resolution: {integrity: sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw==}
|
||||||
@@ -4374,6 +4418,30 @@ packages:
|
|||||||
is-docker: 2.2.1
|
is-docker: 2.2.1
|
||||||
is-wsl: 2.2.0
|
is-wsl: 2.2.0
|
||||||
|
|
||||||
|
/openai@4.104.0(zod@3.25.76):
|
||||||
|
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
ws: ^8.18.0
|
||||||
|
zod: ^3.23.8
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ws:
|
||||||
|
optional: true
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 18.19.130
|
||||||
|
'@types/node-fetch': 2.6.13
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
agentkeepalive: 4.6.0
|
||||||
|
form-data-encoder: 1.7.2
|
||||||
|
formdata-node: 4.4.1
|
||||||
|
node-fetch: 2.7.0
|
||||||
|
zod: 3.25.76
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- encoding
|
||||||
|
dev: false
|
||||||
|
|
||||||
/optionator@0.9.4:
|
/optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -5646,7 +5714,6 @@ packages:
|
|||||||
|
|
||||||
/tr46@0.0.3:
|
/tr46@0.0.3:
|
||||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/tr46@3.0.0:
|
/tr46@3.0.0:
|
||||||
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
|
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
|
||||||
@@ -5788,6 +5855,10 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
/undici-types@5.26.5:
|
||||||
|
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/undici-types@6.21.0:
|
/undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@@ -5962,9 +6033,13 @@ packages:
|
|||||||
'@vue/shared': 3.5.22
|
'@vue/shared': 3.5.22
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
/web-streams-polyfill@4.0.0-beta.3:
|
||||||
|
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/webidl-conversions@3.0.1:
|
/webidl-conversions@3.0.1:
|
||||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/webidl-conversions@7.0.0:
|
/webidl-conversions@7.0.0:
|
||||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||||
@@ -5984,7 +6059,6 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
webidl-conversions: 3.0.1
|
webidl-conversions: 3.0.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/which-module@2.0.1:
|
/which-module@2.0.1:
|
||||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|||||||
Reference in New Issue
Block a user