fix:优化匹配规则
This commit is contained in:
31
apps/backend/src/models/notification-rule.model.ts
Normal file
31
apps/backend/src/models/notification-rule.model.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||||
|
|
||||||
|
const notificationRuleSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String, required: true },
|
||||||
|
enabled: { type: Boolean, default: true },
|
||||||
|
priority: { type: Number, default: 50 },
|
||||||
|
|
||||||
|
// Match scope
|
||||||
|
packageName: { type: String }, // optional: limit to a package/channel
|
||||||
|
keywords: { type: [String], default: [] }, // any keyword match in title/body
|
||||||
|
pattern: { type: String }, // optional regex string
|
||||||
|
|
||||||
|
// Action
|
||||||
|
action: { type: String, enum: ['record', 'ignore'], default: 'record' },
|
||||||
|
category: { type: String }, // override category when action is record
|
||||||
|
type: { type: String, enum: ['income', 'expense'] }, // override type
|
||||||
|
amountPattern: { type: String }, // custom regex for extracting amount
|
||||||
|
|
||||||
|
// Telemetry
|
||||||
|
matchCount: { type: Number, default: 0 },
|
||||||
|
lastMatchedAt: { type: Date }
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type NotificationRuleDocument = InferSchemaType<typeof notificationRuleSchema>;
|
||||||
|
|
||||||
|
export const NotificationRuleModel =
|
||||||
|
mongoose.models.NotificationRule ?? mongoose.model('NotificationRule', notificationRuleSchema);
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createRule, deleteRule, listRules, updateRule } from './notification-rules.service.js';
|
||||||
|
|
||||||
|
const ruleSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
priority: z.number().int().min(0).max(100).optional(),
|
||||||
|
packageName: z.string().optional(),
|
||||||
|
keywords: z.array(z.string()).optional(),
|
||||||
|
pattern: z.string().optional(),
|
||||||
|
action: z.enum(['record', 'ignore']).optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
type: z.enum(['income', 'expense']).optional(),
|
||||||
|
amountPattern: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listRulesHandler(_req: Request, res: Response) {
|
||||||
|
const data = await listRules();
|
||||||
|
return res.json({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRuleHandler(req: Request, res: Response) {
|
||||||
|
const payload = ruleSchema.parse(req.body);
|
||||||
|
const data = await createRule(payload as any);
|
||||||
|
return res.status(201).json({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRuleHandler(req: Request, res: Response) {
|
||||||
|
const payload = ruleSchema.partial().parse(req.body);
|
||||||
|
const data = await updateRule(req.params.id, payload as any);
|
||||||
|
if (!data) return res.status(404).json({ message: 'Rule not found' });
|
||||||
|
return res.json({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRuleHandler(req: Request, res: Response) {
|
||||||
|
const ok = await deleteRule(req.params.id);
|
||||||
|
if (!ok) return res.status(404).json({ message: 'Rule not found' });
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
listRulesHandler,
|
||||||
|
createRuleHandler,
|
||||||
|
updateRuleHandler,
|
||||||
|
deleteRuleHandler
|
||||||
|
} from './notification-rules.controller.js';
|
||||||
|
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', listRulesHandler);
|
||||||
|
router.post('/', createRuleHandler);
|
||||||
|
router.patch(
|
||||||
|
'/:id',
|
||||||
|
validateRequest({ params: z.object({ id: z.string().min(1) }) }),
|
||||||
|
updateRuleHandler
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
'/:id',
|
||||||
|
validateRequest({ params: z.object({ id: z.string().min(1) }) }),
|
||||||
|
deleteRuleHandler
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import {
|
||||||
|
NotificationRuleModel,
|
||||||
|
type NotificationRuleDocument
|
||||||
|
} from '../../models/notification-rule.model.js';
|
||||||
|
|
||||||
|
export interface NotificationRuleDto {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDto = (doc: NotificationRuleDocument & { _id: mongoose.Types.ObjectId }): NotificationRuleDto => ({
|
||||||
|
id: doc._id.toString(),
|
||||||
|
name: doc.name,
|
||||||
|
enabled: doc.enabled ?? true,
|
||||||
|
priority: doc.priority ?? 50,
|
||||||
|
packageName: doc.packageName ?? undefined,
|
||||||
|
keywords: doc.keywords ?? [],
|
||||||
|
pattern: doc.pattern ?? undefined,
|
||||||
|
action: (doc.action as 'record' | 'ignore') ?? 'record',
|
||||||
|
category: doc.category ?? undefined,
|
||||||
|
type: doc.type as any,
|
||||||
|
amountPattern: doc.amountPattern ?? undefined,
|
||||||
|
matchCount: doc.matchCount ?? 0,
|
||||||
|
lastMatchedAt: doc.lastMatchedAt ? new Date(doc.lastMatchedAt).toISOString() : undefined,
|
||||||
|
createdAt: (doc as any).createdAt ? new Date((doc as any).createdAt).toISOString() : undefined,
|
||||||
|
updatedAt: (doc as any).updatedAt ? new Date((doc as any).updatedAt).toISOString() : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function listRules(): Promise<NotificationRuleDto[]> {
|
||||||
|
const docs = await NotificationRuleModel.find().sort({ priority: -1, createdAt: 1 }).lean().exec();
|
||||||
|
return docs.map((d) => toDto(d as NotificationRuleDocument & { _id: mongoose.Types.ObjectId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRule(payload: Omit<NotificationRuleDto, 'id' | 'matchCount' | 'lastMatchedAt' | 'createdAt' | 'updatedAt'>): Promise<NotificationRuleDto> {
|
||||||
|
const created = await NotificationRuleModel.create({
|
||||||
|
name: payload.name,
|
||||||
|
enabled: payload.enabled ?? true,
|
||||||
|
priority: payload.priority ?? 50,
|
||||||
|
packageName: payload.packageName ?? undefined,
|
||||||
|
keywords: payload.keywords ?? [],
|
||||||
|
pattern: payload.pattern ?? undefined,
|
||||||
|
action: payload.action ?? 'record',
|
||||||
|
category: payload.category ?? undefined,
|
||||||
|
type: payload.type ?? undefined,
|
||||||
|
amountPattern: payload.amountPattern ?? undefined
|
||||||
|
});
|
||||||
|
const doc = await NotificationRuleModel.findById(created._id).lean().exec();
|
||||||
|
return toDto(doc as NotificationRuleDocument & { _id: mongoose.Types.ObjectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRule(
|
||||||
|
id: string,
|
||||||
|
payload: Partial<Omit<NotificationRuleDto, 'id' | 'matchCount' | 'lastMatchedAt' | 'createdAt' | 'updatedAt'>>
|
||||||
|
): Promise<NotificationRuleDto | null> {
|
||||||
|
const updated = await NotificationRuleModel.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
...payload
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
)
|
||||||
|
.lean<NotificationRuleDocument & { _id: mongoose.Types.ObjectId }>()
|
||||||
|
.exec();
|
||||||
|
return updated ? toDto(updated) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRule(id: string): Promise<boolean> {
|
||||||
|
const res = await NotificationRuleModel.findByIdAndDelete(id).exec();
|
||||||
|
return res !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleMatchResult {
|
||||||
|
ignore: boolean;
|
||||||
|
overrides: {
|
||||||
|
category?: string;
|
||||||
|
type?: 'income' | 'expense';
|
||||||
|
amountPattern?: string;
|
||||||
|
};
|
||||||
|
matchedRule?: NotificationRuleDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRegex(pattern?: string): RegExp | null {
|
||||||
|
if (!pattern) return null;
|
||||||
|
try {
|
||||||
|
return new RegExp(pattern, 'i');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyNotificationRules(input: {
|
||||||
|
packageName: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}): Promise<RuleMatchResult> {
|
||||||
|
const text = `${input.title ?? ''} ${input.body ?? ''}`;
|
||||||
|
const rules = await NotificationRuleModel.find({ enabled: true }).sort({ priority: -1, createdAt: 1 }).lean().exec();
|
||||||
|
|
||||||
|
for (const r of rules as Array<NotificationRuleDocument & { _id: mongoose.Types.ObjectId }>) {
|
||||||
|
if (r.packageName && r.packageName !== input.packageName) continue;
|
||||||
|
|
||||||
|
let matched = false;
|
||||||
|
if (r.keywords && r.keywords.length > 0) {
|
||||||
|
matched = r.keywords.some((kw) => kw && (text.includes(kw) || input.title?.includes(kw)));
|
||||||
|
}
|
||||||
|
if (!matched && r.pattern) {
|
||||||
|
const regex = safeRegex(r.pattern);
|
||||||
|
if (regex && regex.test(text)) matched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
// update telemetry
|
||||||
|
void NotificationRuleModel.updateOne(
|
||||||
|
{ _id: r._id },
|
||||||
|
{ $inc: { matchCount: 1 }, $set: { lastMatchedAt: new Date() } }
|
||||||
|
).exec();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ignore: r.action === 'ignore',
|
||||||
|
overrides: {
|
||||||
|
category: r.category ?? undefined,
|
||||||
|
type: (r.type as any) ?? undefined,
|
||||||
|
amountPattern: r.amountPattern ?? undefined
|
||||||
|
},
|
||||||
|
matchedRule: toDto(r)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ignore: false, overrides: {} };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -95,6 +95,10 @@ export const createTransactionFromNotificationHandler = async (req: Request, res
|
|||||||
receivedAt: new Date(receivedAt)
|
receivedAt: new Date(receivedAt)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(202).json({
|
return res.status(202).json({
|
||||||
message: 'Notification ingested and awaiting confirmation',
|
message: 'Notification ingested and awaiting confirmation',
|
||||||
data: transaction
|
data: transaction
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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';
|
||||||
|
|
||||||
export interface TransactionDto {
|
export interface TransactionDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -156,19 +157,35 @@ interface NotificationPayload {
|
|||||||
receivedAt: Date;
|
receivedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
|
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto | null> {
|
||||||
const now = payload.receivedAt;
|
const now = payload.receivedAt;
|
||||||
const channel = await getChannelByPackage(payload.packageName);
|
const channel = await getChannelByPackage(payload.packageName);
|
||||||
if (!channel || !channel.enabled) {
|
if (!channel || !channel.enabled) {
|
||||||
throw Object.assign(new Error('Notification channel disabled'), { statusCode: 403 });
|
throw Object.assign(new Error('Notification channel disabled'), { statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply DB-based rules first (blacklist/overrides)
|
||||||
|
const ruleResult = await applyNotificationRules({
|
||||||
|
packageName: payload.packageName,
|
||||||
|
title: payload.title ?? '',
|
||||||
|
body: payload.body ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ruleResult.ignore) {
|
||||||
|
return null; // blacklisted by rule
|
||||||
|
}
|
||||||
|
|
||||||
const template =
|
const template =
|
||||||
channel.templates?.find((tpl: { keywords?: string[] }) =>
|
channel.templates?.find((tpl: { keywords?: string[] }) =>
|
||||||
tpl.keywords?.some((keyword: string) => keyword && (payload.title?.includes(keyword) || payload.body?.includes(keyword)))
|
tpl.keywords?.some((keyword: string) => keyword && (payload.title?.includes(keyword) || payload.body?.includes(keyword)))
|
||||||
) ?? null;
|
) ?? null;
|
||||||
|
|
||||||
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', template);
|
const mergedTemplate = {
|
||||||
|
...(template ?? {}),
|
||||||
|
...(ruleResult.overrides ?? {})
|
||||||
|
} as Parameters<typeof parseNotificationPayload>[2];
|
||||||
|
|
||||||
|
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', mergedTemplate);
|
||||||
|
|
||||||
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');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import transactionsRouter from '../modules/transactions/transactions.router.js';
|
|||||||
import analysisRouter from '../modules/analysis/analysis.router.js';
|
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';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -16,5 +17,6 @@ router.use('/transactions', transactionsRouter);
|
|||||||
router.use('/analysis', analysisRouter);
|
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);
|
||||||
|
|
||||||
export { router };
|
export { router };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import OverviewCard from '../../../components/cards/OverviewCard.vue';
|
import OverviewCard from '../../../components/cards/OverviewCard.vue';
|
||||||
import QuickActionButton from '../../../components/actions/QuickActionButton.vue';
|
import QuickActionButton from '../../../components/actions/QuickActionButton.vue';
|
||||||
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
|
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
|
||||||
@@ -8,6 +8,8 @@ import { useAuthStore } from '../../../stores/auth';
|
|||||||
import { useTransactionsQuery } from '../../../composables/useTransactions';
|
import { useTransactionsQuery } from '../../../composables/useTransactions';
|
||||||
import { useBudgetsQuery } from '../../../composables/useBudgets';
|
import { useBudgetsQuery } from '../../../composables/useBudgets';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { App, type AppState } from '@capacitor/app';
|
||||||
|
import type { PluginListenerHandle } from '@capacitor/core';
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -67,6 +69,25 @@ const budgetAlerts = computed(() =>
|
|||||||
(budget) => budget.amount > 0 && (budget.usage / budget.amount >= budget.threshold || budget.usage >= budget.amount)
|
(budget) => budget.amount > 0 && (budget.usage / budget.amount >= budget.threshold || budget.usage >= budget.amount)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let appStateListener: PluginListenerHandle | null = null;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Ensure we fetch latest on mount/foreground
|
||||||
|
await Promise.all([transactionsQuery.refetch(), budgetsQuery.refetch()]);
|
||||||
|
appStateListener = await App.addListener('appStateChange', async ({ isActive }: AppState) => {
|
||||||
|
if (isActive) {
|
||||||
|
await Promise.all([transactionsQuery.refetch(), budgetsQuery.refetch()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (appStateListener) {
|
||||||
|
void appStateListener.remove();
|
||||||
|
appStateListener = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -136,7 +157,7 @@ const budgetAlerts = computed(() =>
|
|||||||
<h2 class="text-xl font-semibold text-gray-900">近期交易</h2>
|
<h2 class="text-xl font-semibold text-gray-900">近期交易</h2>
|
||||||
<RouterLink to="/transactions" class="text-sm text-indigo-500">查看全部</RouterLink>
|
<RouterLink to="/transactions" class="text-sm text-indigo-500">查看全部</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 mt-4 pb-10">
|
<div class="space-y-6 mt-4 pb-10">
|
||||||
<div v-if="isInitialLoading" class="text-sm text-gray-400 text-center py-10">
|
<div v-if="isInitialLoading" class="text-sm text-gray-400 text-center py-10">
|
||||||
数据加载中...
|
数据加载中...
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ const submit = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeTransaction = async (id: string) => {
|
const removeTransaction = async (id: string) => {
|
||||||
|
// 防误触:增加确认
|
||||||
|
const ok = window.confirm('确认删除该交易吗?');
|
||||||
|
if (!ok) return;
|
||||||
try {
|
try {
|
||||||
await deleteTransaction.mutateAsync(id);
|
await deleteTransaction.mutateAsync(id);
|
||||||
showFeedback('交易已删除');
|
showFeedback('交易已删除');
|
||||||
@@ -178,24 +181,26 @@ const refreshTransactions = async () => {
|
|||||||
暂无交易记录,点击「新增」开始记账。
|
暂无交易记录,点击「新增」开始记账。
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div
|
<div class="space-y-4">
|
||||||
v-for="transaction in filteredTransactions"
|
<div
|
||||||
:key="transaction.id"
|
v-for="transaction in filteredTransactions"
|
||||||
class="relative group"
|
:key="transaction.id"
|
||||||
>
|
class="relative group"
|
||||||
<RouterLink
|
|
||||||
class="block"
|
|
||||||
:to="{ name: 'transaction-detail', params: { id: transaction.id } }"
|
|
||||||
>
|
>
|
||||||
<TransactionItem :transaction="transaction" />
|
<RouterLink
|
||||||
</RouterLink>
|
class="block"
|
||||||
<button
|
:to="{ name: 'transaction-detail', params: { id: transaction.id } }"
|
||||||
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 z-10"
|
>
|
||||||
@click.stop="removeTransaction(transaction.id)"
|
<TransactionItem :transaction="transaction" />
|
||||||
aria-label="删除"
|
</RouterLink>
|
||||||
>
|
<button
|
||||||
<LucideIcon name="trash-2" :size="18" />
|
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 z-10"
|
||||||
</button>
|
@click.stop="removeTransaction(transaction.id)"
|
||||||
|
aria-label="删除"
|
||||||
|
>
|
||||||
|
<LucideIcon name="trash-2" :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user