feat: add Telegram notification settings UI
Some checks failed
Deploy to Production / Build and Test (push) Has been cancelled
Deploy to Production / Deploy to Server (push) Has been cancelled

This commit is contained in:
你的用户名
2025-11-05 02:22:00 +08:00
parent 6108b9c5ed
commit a06a964bab
7 changed files with 21297 additions and 25 deletions

View File

@@ -1,3 +1,5 @@
export * from './auth'; export * from './auth';
export * from './finance';
export * from './menu'; export * from './menu';
export * from './telegram';
export * from './user'; export * from './user';

View File

@@ -0,0 +1,70 @@
import { requestClient } from '#/api/request';
export namespace TelegramApi {
export interface NotificationConfig {
id: number;
name: string;
botToken: string;
chatId: string;
notificationTypes: string[];
isEnabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateNotificationConfigParams {
name: string;
botToken: string;
chatId: string;
notificationTypes?: string[];
isEnabled?: boolean;
}
export interface UpdateNotificationConfigParams {
name?: string;
botToken?: string;
chatId?: string;
notificationTypes?: string[];
isEnabled?: boolean;
}
export interface TestNotificationConfigParams {
botToken: string;
chatId: string;
}
}
export function getTelegramNotificationConfigs() {
return requestClient.get<TelegramApi.NotificationConfig[]>(
'/telegram/notifications',
);
}
export function createTelegramNotificationConfig(
data: TelegramApi.CreateNotificationConfigParams,
) {
return requestClient.post<TelegramApi.NotificationConfig>(
'/telegram/notifications',
data,
);
}
export function updateTelegramNotificationConfig(
id: number,
data: TelegramApi.UpdateNotificationConfigParams,
) {
return requestClient.put<TelegramApi.NotificationConfig>(
`/telegram/notifications/${id}`,
data,
);
}
export function deleteTelegramNotificationConfig(id: number) {
return requestClient.delete<{ id: number }>(`/telegram/notifications/${id}`);
}
export function testTelegramNotificationConfig(
data: TelegramApi.TestNotificationConfigParams,
) {
return requestClient.post<{ message: string }>('/telegram/test', data);
}

View File

@@ -1,17 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import type { TableColumnsType } from 'ant-design-vue';
import { onMounted, reactive, ref } from 'vue';
import { import {
Button, Button,
Card, Card,
Divider, Divider,
Form, Form,
Input,
Modal, Modal,
notification, notification,
Space,
Switch, Switch,
Table,
Tag, Tag,
} from 'ant-design-vue'; } from 'ant-design-vue';
import {
createTelegramNotificationConfig,
deleteTelegramNotificationConfig,
getTelegramNotificationConfigs,
TelegramApi,
testTelegramNotificationConfig,
updateTelegramNotificationConfig,
} from '#/api/core/telegram';
defineOptions({ name: 'FinanceSettings' }); defineOptions({ name: 'FinanceSettings' });
// 系统设置 // 系统设置
@@ -36,18 +50,358 @@ const operationLoading = ref({
reset: false, reset: false,
}); });
// 功能方法 interface TelegramConfigForm {
const saveCurrencySettings = (currency: string) => { name: string;
console.log('货币设置更改为:', currency); botToken: string;
localStorage.setItem('app-currency', currency); chatId: string;
notification.success({ isEnabled: boolean;
message: '货币设置已更新', notificationTypes: string[];
description: `默认货币已设置为 ${currency}`, }
const telegramConfigs = ref<TelegramApi.NotificationConfig[]>([]);
const telegramLoading = ref(false);
const telegramModalVisible = ref(false);
const telegramModalLoading = ref(false);
const telegramTestLoading = ref(false);
const testingRowId = ref<null | number>(null);
const togglingConfigId = ref<null | number>(null);
const editingTelegramConfig = ref<null | TelegramApi.NotificationConfig>(null);
const telegramForm = reactive<TelegramConfigForm>({
name: '',
botToken: '',
chatId: '',
isEnabled: true,
notificationTypes: ['transaction'],
}); });
};
const telegramColumns: TableColumnsType<TelegramApi.NotificationConfig> = [
{
title: '配置名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: 'Bot Token',
key: 'botToken',
width: 260,
},
{
title: 'Chat ID',
dataIndex: 'chatId',
key: 'chatId',
width: 200,
},
{
title: '通知类型',
key: 'notificationTypes',
width: 200,
},
{
title: '启用状态',
key: 'isEnabled',
width: 140,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 200,
},
{
title: '操作',
key: 'actions',
fixed: 'right',
width: 220,
},
];
function resetTelegramForm() {
telegramForm.name = '';
telegramForm.botToken = '';
telegramForm.chatId = '';
telegramForm.isEnabled = true;
telegramForm.notificationTypes = ['transaction'];
}
function validateTelegramForm() {
if (!telegramForm.name.trim()) {
notification.error({
message: '请填写配置名称',
description: '例如:财务通知群或个人提醒',
});
return false;
}
if (!telegramForm.botToken.trim()) {
notification.error({
message: '请填写 Bot Token',
description: '可从 @BotFather 获取完整的 Bot Token',
});
return false;
}
if (!telegramForm.chatId.trim()) {
notification.error({
message: '请填写 Chat ID',
description: '个人或群组的 Chat ID 不能为空',
});
return false;
}
return true;
}
async function fetchTelegramConfigs() {
telegramLoading.value = true;
try {
telegramConfigs.value = await getTelegramNotificationConfigs();
} catch (error) {
console.error('加载 Telegram 配置失败:', error);
notification.error({
message: '加载 Telegram 配置失败',
description: '请稍后重试或检查后端服务状态',
});
} finally {
telegramLoading.value = false;
}
}
function openCreateTelegramConfig() {
editingTelegramConfig.value = null;
resetTelegramForm();
telegramModalVisible.value = true;
}
function openEditTelegramConfig(config: TelegramApi.NotificationConfig) {
editingTelegramConfig.value = config;
telegramForm.name = config.name;
telegramForm.botToken = config.botToken;
telegramForm.chatId = config.chatId;
telegramForm.isEnabled = config.isEnabled;
telegramForm.notificationTypes = [...config.notificationTypes];
telegramModalVisible.value = true;
}
function handleTelegramCancel() {
telegramModalVisible.value = false;
editingTelegramConfig.value = null;
resetTelegramForm();
}
async function handleModalTestTelegramConfig() {
const botToken = telegramForm.botToken.trim();
const chatId = telegramForm.chatId.trim();
if (!botToken || !chatId) {
notification.warning({
message: '请先填写完整的 Bot Token 和 Chat ID',
});
return;
}
telegramForm.botToken = botToken;
telegramForm.chatId = chatId;
telegramTestLoading.value = true;
try {
await testTelegramNotificationConfig({ botToken, chatId });
notification.success({
message: '测试消息已发送',
description: '请在 Telegram 中检查是否收到测试通知',
});
} catch (error) {
console.error('Telegram 测试失败:', error);
notification.error({
message: '测试失败',
description: '请检查 Bot Token、Chat ID 或网络连接',
});
} finally {
telegramTestLoading.value = false;
}
}
async function handleTelegramSubmit() {
if (!validateTelegramForm()) {
return;
}
const name = telegramForm.name.trim();
const botToken = telegramForm.botToken.trim();
const chatId = telegramForm.chatId.trim();
telegramForm.name = name;
telegramForm.botToken = botToken;
telegramForm.chatId = chatId;
telegramModalLoading.value = true;
try {
if (editingTelegramConfig.value) {
const payload: TelegramApi.UpdateNotificationConfigParams = {};
if (name !== editingTelegramConfig.value.name) {
payload.name = name;
}
if (botToken !== editingTelegramConfig.value.botToken) {
payload.botToken = botToken;
}
if (chatId !== editingTelegramConfig.value.chatId) {
payload.chatId = chatId;
}
if (
telegramForm.notificationTypes.join(',') !==
editingTelegramConfig.value.notificationTypes.join(',')
) {
payload.notificationTypes = [...telegramForm.notificationTypes];
}
if (telegramForm.isEnabled !== editingTelegramConfig.value.isEnabled) {
payload.isEnabled = telegramForm.isEnabled;
}
if (Object.keys(payload).length === 0) {
notification.info({
message: '配置未发生变化',
description: '如需更新请修改字段后再保存',
});
return;
}
await updateTelegramNotificationConfig(
editingTelegramConfig.value.id,
payload,
);
notification.success({
message: '配置已更新',
description: `${name}」已保存最新配置`,
});
} else {
await createTelegramNotificationConfig({
name,
botToken,
chatId,
notificationTypes: [...telegramForm.notificationTypes],
isEnabled: telegramForm.isEnabled,
});
notification.success({
message: '配置已创建',
description: `${name}」已加入通知列表`,
});
}
await fetchTelegramConfigs();
handleTelegramCancel();
} catch (error) {
console.error('保存 Telegram 配置失败:', error);
notification.error({
message: '保存失败',
description: '请检查信息是否正确或稍后再试',
});
} finally {
telegramModalLoading.value = false;
}
}
async function handleToggleTelegramConfig(
config: TelegramApi.NotificationConfig,
value: boolean,
) {
togglingConfigId.value = config.id;
try {
await updateTelegramNotificationConfig(config.id, { isEnabled: value });
await fetchTelegramConfigs();
notification.success({
message: value ? '配置已启用' : '配置已禁用',
description: `${config.name}」通知状态已更新`,
});
} catch (error) {
console.error('更新 Telegram 状态失败:', error);
notification.error({
message: '状态更新失败',
description: '请稍后重试',
});
} finally {
togglingConfigId.value = null;
}
}
async function handleTestExistingConfig(
config: TelegramApi.NotificationConfig,
) {
testingRowId.value = config.id;
try {
await testTelegramNotificationConfig({
botToken: config.botToken,
chatId: config.chatId,
});
notification.success({
message: '测试消息已发送',
description: `请在 Telegram 检查「${config.name}`,
});
} catch (error) {
console.error('测试 Telegram 配置失败:', error);
notification.error({
message: '测试失败',
description: '请检查 Bot Token 和 Chat ID',
});
} finally {
testingRowId.value = null;
}
}
function handleDeleteTelegramConfig(config: TelegramApi.NotificationConfig) {
Modal.confirm({
title: `确认删除配置「${config.name}」?`,
content: '删除后将无法继续向该目标发送 Telegram 通知。',
okText: '删除',
cancelText: '取消',
okButtonProps: { danger: true },
async onOk() {
try {
await deleteTelegramNotificationConfig(config.id);
notification.success({
message: '配置已删除',
description: `${config.name}」已移除`,
});
await fetchTelegramConfigs();
} catch (error) {
console.error('删除 Telegram 配置失败:', error);
notification.error({
message: '删除失败',
description: '请稍后重试',
});
throw error;
}
},
});
}
function formatDateTime(value: string) {
try {
return new Date(value).toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
hour12: false,
});
} catch {
return value;
}
}
function maskToken(token: string) {
if (token.length <= 10) {
return token;
}
return `${token.slice(0, 6)}...${token.slice(-4)}`;
}
const saveNotificationSettings = () => { const saveNotificationSettings = () => {
console.log('通知设置已保存:', settings.value.notifications);
localStorage.setItem( localStorage.setItem(
'app-notifications', 'app-notifications',
JSON.stringify(settings.value.notifications), JSON.stringify(settings.value.notifications),
@@ -59,7 +413,6 @@ const saveNotificationSettings = () => {
}; };
const toggleAutoBackup = (enabled: boolean) => { const toggleAutoBackup = (enabled: boolean) => {
console.log('自动备份:', enabled);
localStorage.setItem('app-auto-backup', enabled.toString()); localStorage.setItem('app-auto-backup', enabled.toString());
notification.info({ notification.info({
message: enabled ? '自动备份已启用' : '自动备份已禁用', message: enabled ? '自动备份已启用' : '自动备份已禁用',
@@ -68,7 +421,6 @@ const toggleAutoBackup = (enabled: boolean) => {
}; };
const toggleCompactMode = (enabled: boolean) => { const toggleCompactMode = (enabled: boolean) => {
console.log('紧凑模式:', enabled);
document.documentElement.classList.toggle('compact', enabled); document.documentElement.classList.toggle('compact', enabled);
localStorage.setItem('app-compact-mode', enabled.toString()); localStorage.setItem('app-compact-mode', enabled.toString());
notification.info({ notification.info({
@@ -77,7 +429,6 @@ const toggleCompactMode = (enabled: boolean) => {
}; };
const toggleAutoLock = (enabled: boolean) => { const toggleAutoLock = (enabled: boolean) => {
console.log('自动锁屏:', enabled);
localStorage.setItem('app-auto-lock', enabled.toString()); localStorage.setItem('app-auto-lock', enabled.toString());
notification.info({ notification.info({
message: enabled ? '自动锁屏已启用' : '自动锁屏已禁用', message: enabled ? '自动锁屏已启用' : '自动锁屏已禁用',
@@ -85,7 +436,6 @@ const toggleAutoLock = (enabled: boolean) => {
}; };
const toggleAnalytics = (enabled: boolean) => { const toggleAnalytics = (enabled: boolean) => {
console.log('数据统计:', enabled);
localStorage.setItem('app-analytics', enabled.toString()); localStorage.setItem('app-analytics', enabled.toString());
notification.info({ notification.info({
message: enabled ? '数据统计已启用' : '数据统计已禁用', message: enabled ? '数据统计已启用' : '数据统计已禁用',
@@ -239,7 +589,6 @@ const resetSystem = () => {
}; };
const saveAllSettings = () => { const saveAllSettings = () => {
console.log('保存所有设置:', settings.value);
localStorage.setItem('app-all-settings', JSON.stringify(settings.value)); localStorage.setItem('app-all-settings', JSON.stringify(settings.value));
notification.success({ notification.success({
message: '设置保存成功', message: '设置保存成功',
@@ -306,7 +655,7 @@ onMounted(() => {
console.error('设置恢复失败:', error); console.error('设置恢复失败:', error);
} }
console.log('系统设置页面加载完成'); fetchTelegramConfigs();
}); });
</script> </script>
@@ -440,7 +789,156 @@ onMounted(() => {
</Button> </Button>
</div> </div>
</Card> </Card>
<Card class="lg:col-span-2">
<template #title>🚀 Telegram 通知配置</template>
<template #extra>
<Button type="primary" @click="openCreateTelegramConfig">
新增配置
</Button>
</template>
<Table
:columns="telegramColumns"
:data-source="telegramConfigs"
:loading="telegramLoading"
:pagination="false"
:row-key="(record) => record.id"
:scroll="{ x: 960 }"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'botToken'">
<span class="font-mono text-xs">{{
maskToken(record.botToken)
}}</span>
</template>
<template v-else-if="column.key === 'notificationTypes'">
<Space :size="4" wrap>
<Tag
v-for="item in record.notificationTypes"
:key="item"
color="blue"
>
{{ item }}
</Tag>
</Space>
</template>
<template v-else-if="column.key === 'isEnabled'">
<Switch
:checked="record.isEnabled"
:loading="togglingConfigId === record.id"
@change="(value) => handleToggleTelegramConfig(record, value)"
/>
</template>
<template v-else-if="column.dataIndex === 'updatedAt'">
{{ formatDateTime(record.updatedAt) }}
</template>
<template v-else-if="column.key === 'actions'">
<Space size="small">
<Button
size="small"
type="link"
@click="openEditTelegramConfig(record)"
>
编辑
</Button>
<Button
size="small"
type="link"
:loading="testingRowId === record.id"
@click="handleTestExistingConfig(record)"
>
测试
</Button>
<Button
size="small"
type="link"
danger
@click="handleDeleteTelegramConfig(record)"
>
删除
</Button>
</Space>
</template>
</template>
</Table>
</Card>
</div> </div>
<Modal
v-model:open="telegramModalVisible"
:confirm-loading="telegramModalLoading"
:title="editingTelegramConfig ? '编辑通知配置' : '新增通知配置'"
destroy-on-close
width="520px"
@cancel="handleTelegramCancel"
>
<Form layout="vertical">
<Form.Item label="配置名称" required>
<Input
v-model:value="telegramForm.name"
placeholder="例如财务通知群"
maxlength="100"
/>
</Form.Item>
<Form.Item label="Bot Token" required>
<Input
v-model:value="telegramForm.botToken"
placeholder="1234567890:ABCdefGHI..."
maxlength="255"
/>
<p class="mt-1 text-xs text-gray-500">
从 Telegram 的 @BotFather 获取完整的 Bot Token
</p>
</Form.Item>
<Form.Item label="Chat ID" required>
<Input
v-model:value="telegramForm.chatId"
placeholder="-1001234567890"
maxlength="64"
/>
<p class="mt-1 text-xs text-gray-500">
个人可使用 @userinfobot 查询,群组需将 Bot 加入后获取
</p>
</Form.Item>
<Form.Item label="启用状态">
<Switch v-model:checked="telegramForm.isEnabled" />
</Form.Item>
<Form.Item label="通知类型">
<Space :size="4" wrap>
<Tag
v-for="item in telegramForm.notificationTypes"
:key="item"
color="blue"
>
{{ item }}
</Tag>
</Space>
</Form.Item>
</Form>
<template #footer>
<Space>
<Button @click="handleTelegramCancel">取消</Button>
<Button
:loading="telegramTestLoading"
@click="handleModalTestTelegramConfig"
>
发送测试
</Button>
<Button
type="primary"
:loading="telegramModalLoading"
@click="handleTelegramSubmit"
>
{{ editingTelegramConfig ? '保存更新' : '创建配置' }}
</Button>
</Space>
</template>
</Modal>
</div> </div>
</template> </template>

View File

@@ -25,11 +25,13 @@ KT财务系统支持通过Telegram Bot向群组或个人发送账目记录通知
### 2. 获取Chat ID ### 2. 获取Chat ID
#### 获取个人Chat ID #### 获取个人Chat ID
1. 在Telegram中搜索 `@userinfobot` 1. 在Telegram中搜索 `@userinfobot`
2. 发送任意消息 2. 发送任意消息
3. Bot会返回你的Chat ID 3. Bot会返回你的Chat ID
#### 获取群组Chat ID #### 获取群组Chat ID
1. 将你的Bot添加到群组 1. 将你的Bot添加到群组
2. 在群组中发送任意消息 2. 在群组中发送任意消息
3. 访问:`https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates` 3. 访问:`https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates`
@@ -44,6 +46,7 @@ GET /api/telegram/notifications
``` ```
**响应示例** **响应示例**
```json ```json
{ {
"code": 0, "code": 0,
@@ -78,6 +81,7 @@ Content-Type: application/json
``` ```
**说明** **说明**
- `name`: 配置名称(必填) - `name`: 配置名称(必填)
- `botToken`: Telegram Bot Token必填 - `botToken`: Telegram Bot Token必填
- `chatId`: 目标聊天ID必填 - `chatId`: 目标聊天ID必填
@@ -85,6 +89,7 @@ Content-Type: application/json
- `isEnabled`: 是否启用(可选,默认:`true` - `isEnabled`: 是否启用(可选,默认:`true`
**响应示例** **响应示例**
```json ```json
{ {
"code": 0, "code": 0,
@@ -159,9 +164,11 @@ Content-Type: application/json
## 通知类型说明 ## 通知类型说明
目前支持的通知类型: 目前支持的通知类型:
- `transaction`: 交易记录通知(新增、更新、删除账目) - `transaction`: 交易记录通知(新增、更新、删除账目)
未来可扩展: 未来可扩展:
- `budget`: 预算提醒 - `budget`: 预算提醒
- `report`: 财务报表 - `report`: 财务报表
- `reimbursement`: 报销审批 - `reimbursement`: 报销审批
@@ -169,18 +176,23 @@ Content-Type: application/json
## 常见问题 ## 常见问题
### Q: Bot无法发送消息到群组 ### Q: Bot无法发送消息到群组
**A**: 请确保: **A**: 请确保:
1. Bot已被添加到群组 1. Bot已被添加到群组
2. Bot在群组中有发送消息的权限 2. Bot在群组中有发送消息的权限
3. Chat ID正确群组ID通常是负数 3. Chat ID正确群组ID通常是负数
### Q: 如何禁用某个配置的通知? ### Q: 如何禁用某个配置的通知?
**A**: 调用更新API设置 `isEnabled: false` **A**: 调用更新API设置 `isEnabled: false`
### Q: 可以配置多个Bot吗 ### Q: 可以配置多个Bot吗
**A**: 可以系统支持多个Bot配置所有启用的配置都会收到通知。 **A**: 可以系统支持多个Bot配置所有启用的配置都会收到通知。
### Q: 消息会包含敏感信息吗? ### Q: 消息会包含敏感信息吗?
**A**: 消息只包含账目的基本信息(类型、金额、分类等),不包含用户身份等敏感信息。建议使用私密群组。 **A**: 消息只包含账目的基本信息(类型、金额、分类等),不包含用户身份等敏感信息。建议使用私密群组。
## 技术实现 ## 技术实现
@@ -239,9 +251,27 @@ curl -X POST http://localhost:3000/api/finance/transactions \
}' }'
``` ```
## 下一步 ## 前端配置界面
等你提供Telegram Bot Token后我们可以 Telegram 通知现已集成在 Web 端的系统设置页面
1. 在前端添加通知配置管理界面
2. 测试实际的消息发送 - 入口路径:`财务系统 → ⚙️ 系统设置 → Telegram 通知配置`
3. 根据需要调整消息格式和内容 - 列表内容展示配置名称、Bot Token掩码、Chat ID、通知类型、启用状态以及最近更新时间
- 支持操作:快速启用/禁用、编辑、发送测试通知、删除
### 新增或编辑配置
1. 点击「➕ 新增配置」或列表中的「编辑」按钮
2. 填写/更新以下字段:
- **配置名称**:用于标识通知触达对象(例如“财务通知群”)
- **Bot Token**:来自 @BotFather 的完整 Token
- **Chat ID**:个人或群组的 ID群组需将 Bot 加入并通过 `getUpdates` 获取)
- **启用状态**:控制是否参与通知投递
3. 可直接在弹窗内点击「发送测试」,验证 Bot Token 与 Chat ID 是否有效
4. 点击「创建配置」或「保存更新」提交,成功后自动刷新列表
### 使用提示
- 所有启用的配置会在新增账目时同步收到通知
- 「测试」按钮会向对应的聊天发送标准测试消息,方便确认权限
- 删除配置后即刻停止向该聊天推送消息

20669
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
stylelint.config.mjs Normal file
View File

@@ -0,0 +1,3 @@
import config from './internal/lint-configs/stylelint-config/index.mjs';
export default config;

View File

@@ -4,6 +4,10 @@
"name": "@vben/backend", "name": "@vben/backend",
"path": "apps/backend", "path": "apps/backend",
}, },
{
"name": "@vben/finance-mcp-service",
"path": "apps/finance-mcp-service",
},
{ {
"name": "@vben/web-antd", "name": "@vben/web-antd",
"path": "apps/web-antd", "path": "apps/web-antd",
@@ -156,10 +160,6 @@
"name": "@vben/utils", "name": "@vben/utils",
"path": "packages/utils", "path": "packages/utils",
}, },
{
"name": "@vben/playground",
"path": "playground",
},
{ {
"name": "@vben/turbo-run", "name": "@vben/turbo-run",
"path": "scripts/turbo-run", "path": "scripts/turbo-run",