feat(notice+job): 用户管理加通知渠道字段 + 通知模块(规则/渠道/日志) + 自动任务管理页面

This commit is contained in:
夏犀麟 2026-08-27 22:58:41 +08:00
parent 7dc9bc6c2a
commit 8e16bdb2dc
10 changed files with 1153 additions and 14 deletions

View File

@ -1,6 +1,8 @@
# admin.xpcool.com 变更记录 # admin.xpcool.com 变更记录
> 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要 > 倒序最新在上。格式YYYY-MM-DD | 类型 | 摘要
2026-08-27 | FIX | 前后端联查修复「界面/菜单/工作台异常」:①本地库缺 010_server_log_enhance 增量列admin_operation_log 无 ip_location/admin_username 等)致操作日志接口 500已导②本地缺工作台/招聘菜单+admin_role_menu 关联,已导 013_workbench_menu+recruitment/003_menu③工作台快捷入口 /system/log/* 是死链(真实路由 /log/*)已改;④打通 Drawer 重构遗留编译错6 页 @vben-core/popup-ui 误引改 @vben/common-ui、preferences 补 languageToggleButtonPosition、清未用变量、menu/log 页 lint 债7dc9bc6钩子全绿⑤发现审计 admin_username/user_agent 长期为空=线上跑旧二进制,新构建验证 248 号记录字段完整,历史空账号已按 user_id 回填121 条);⑥本地 xxcool/opuser 密码已重置为 opuser123 便于联调
2026-08-27 | CHG | 三大改造①移除国际化多语言仅留中文——preferences.ts 固定 locale=zh-CN删除 header/toolbar/user-dropdown 三处语言切换入口、偏好面板语言选择项(general.vue/appLocale/widget.vue languageToggle 位置配置)config.ts 禁 languageToggle 且 order 移除;删除主应用与 packages/locales 的 en-US 语言包locales/index.ts loadDayjsLocale 仅留 zh-CN。②新增主题色管理——theme.css 品牌色改为引用 --primary 变量(不再写死 #4f5bff),背景/主按钮渐变用 color-mix 跟随主题色,配合 useTDesignDesignTokens 使偏好面板 BuiltinTheme 切换主题色真正生效constants.ts default 主题色与 config.ts colorPrimary 统一为靛蓝 hsl(235 100% 65%)。③业务弹窗统一为 vben 抽屉——6 页(system/{role,menu,admin}、recruitment/push、house/{listing,community})的 t-dialog 全部替换为 useVbenDrawer(<Drawer>)drawerApi.open/close/setState(标题)/lock(提交中)community/listing 抽屉加宽(class w-160/w-150) 2026-08-27 | CHG | 三大改造①移除国际化多语言仅留中文——preferences.ts 固定 locale=zh-CN删除 header/toolbar/user-dropdown 三处语言切换入口、偏好面板语言选择项(general.vue/appLocale/widget.vue languageToggle 位置配置)config.ts 禁 languageToggle 且 order 移除;删除主应用与 packages/locales 的 en-US 语言包locales/index.ts loadDayjsLocale 仅留 zh-CN。②新增主题色管理——theme.css 品牌色改为引用 --primary 变量(不再写死 #4f5bff),背景/主按钮渐变用 color-mix 跟随主题色,配合 useTDesignDesignTokens 使偏好面板 BuiltinTheme 切换主题色真正生效constants.ts default 主题色与 config.ts colorPrimary 统一为靛蓝 hsl(235 100% 65%)。③业务弹窗统一为 vben 抽屉——6 页(system/{role,menu,admin}、recruitment/push、house/{listing,community})的 t-dialog 全部替换为 useVbenDrawer(<Drawer>)drawerApi.open/close/setState(标题)/lock(提交中)community/listing 抽屉加宽(class w-160/w-150)
2026-08-27 | CHG | 服务器安全日志页挂在系统监控菜单下id=96views/monitor/security/index.vue——顶部 4 统计卡片SSH爆破/fail2ban封禁/成功登录/总数)+ TOP 攻击源标签区 + 筛选来源IP/事件类型/时间范围 t-date-range-picker+ Vben VxeTable 列表(时间/来源IP/来源端口/目标端口/事件类型标签/详情 tooltip事件标签按类型配色api/serverSecurity.tslist/stats 全 POST无 URL 参数typecheck 通过 2026-08-27 | CHG | 服务器安全日志页挂在系统监控菜单下id=96views/monitor/security/index.vue——顶部 4 统计卡片SSH爆破/fail2ban封禁/成功登录/总数)+ TOP 攻击源标签区 + 筛选来源IP/事件类型/时间范围 t-date-range-picker+ Vben VxeTable 列表(时间/来源IP/来源端口/目标端口/事件类型标签/详情 tooltip事件标签按类型配色api/serverSecurity.tslist/stats 全 POST无 URL 参数typecheck 通过
2026-08-27 | CHG | 全量表格重构views 下全部 11 个 t-table 页面统一改为 Vben Vxe TableuseVbenVxeGrid#/adapter/vxe-table+ Vben TableAction 行操作——system/{log(范式),login-log,admin,role}、recruitment/{info,crawler,push}、house/{data,listing,community,presale}、dashboard/workbench两处本地小表 proxyConfig.enabled=false + gridApi.setGridOptions 写数据。要点gridOptions 需注解 VxeTableGridOptions否则 type:'seq' 推断 string 报错);单元格自定义用 columns[].slots.default + h(TDesign组件);远程分页走 proxyConfig.ajax.query 返回 {items,total};筛选区保留手写 TDesign 组件不动admin/role/listing/community 行操作 ActionItem 带 auth 权限码、删除 danger 标红listing 多选批量标记改 vxe checkboxConfig + gridApi.grid.getCheckboxRecords()confirm 弹窗交互(删除/重置密码)保留 DialogPlugin 不变typecheck 通过 2026-08-27 | CHG | 全量表格重构views 下全部 11 个 t-table 页面统一改为 Vben Vxe TableuseVbenVxeGrid#/adapter/vxe-table+ Vben TableAction 行操作——system/{log(范式),login-log,admin,role}、recruitment/{info,crawler,push}、house/{data,listing,community,presale}、dashboard/workbench两处本地小表 proxyConfig.enabled=false + gridApi.setGridOptions 写数据。要点gridOptions 需注解 VxeTableGridOptions否则 type:'seq' 推断 string 报错);单元格自定义用 columns[].slots.default + h(TDesign组件);远程分页走 proxyConfig.ajax.query 返回 {items,total};筛选区保留手写 TDesign 组件不动admin/role/listing/community 行操作 ActionItem 带 auth 权限码、删除 danger 标红listing 多选批量标记改 vxe checkboxConfig + gridApi.grid.getCheckboxRecords()confirm 弹窗交互(删除/重置密码)保留 DialogPlugin 不变typecheck 通过

View File

@ -0,0 +1,67 @@
// 自动任务模块 API 客户端(任务管理/运行日志)。
// 接口规范:全部 POSTURL 不含参数;入参一律 body。
import { requestClient } from '#/api/request';
// ---------- 任务 ----------
export interface AutoJobItem {
id: number;
name: string;
code: string;
jobType: string;
cronExpr: string;
enabled: number;
remark: string;
lastRunAt: string;
lastResult: number;
lastError: string;
}
export function getAutoJobList() {
return requestClient.post<{ list: AutoJobItem[] }>(
'/api/service/admin/auto-job/list',
);
}
export function saveAutoJob(data: {
cronExpr?: string;
enabled?: number;
id: number;
remark?: string;
}) {
return requestClient.post('/api/service/admin/auto-job/save', data);
}
export function triggerAutoJob(data: { id: number }) {
return requestClient.post<{ ok: boolean; summary: string }>(
'/api/service/admin/auto-job/trigger',
data,
);
}
// ---------- 运行日志 ----------
export interface AutoJobLogItem {
id: number;
jobId: number;
jobName: string;
runAt: string;
result: number;
error: string;
summary: string;
durationMs: number;
}
export interface AutoJobLogListResp {
list: AutoJobLogItem[];
total: number;
}
export function getAutoJobLogList(data: {
jobId?: number;
page: number;
size: number;
}) {
return requestClient.post<AutoJobLogListResp>(
'/api/service/admin/auto-job/log/list',
data,
);
}

View File

@ -0,0 +1,130 @@
// 通知模块 API 客户端(渠道/规则/日志/测试)。
// 接口规范:全部 POSTURL 不含参数;入参一律 body。
import { requestClient } from '#/api/request';
// ---------- 渠道 ----------
export interface NoticeChannelItem {
id: number;
code: string;
name: string;
enabled: number;
config: string;
remark: string;
}
export function getNoticeChannelList() {
return requestClient.post<{ list: NoticeChannelItem[] }>(
'/api/service/admin/notice/channel/list',
);
}
export function saveNoticeChannel(data: {
code: string;
config?: string;
enabled?: number;
id?: number;
name: string;
remark?: string;
}) {
return requestClient.post<{ id: number }>(
'/api/service/admin/notice/channel/save',
data,
);
}
// ---------- 规则 ----------
export interface NoticeRuleItem {
id: number;
name: string;
eventType: string;
channelCodes: string[];
userIds: number[];
titleTemplate: string;
bodyTemplate: string;
enabled: number;
}
export function getNoticeRuleList(data: { eventType?: string }) {
return requestClient.post<{ list: NoticeRuleItem[] }>(
'/api/service/admin/notice/rule/list',
data,
);
}
export function saveNoticeRule(data: {
bodyTemplate?: string;
channelCodes: string[];
enabled?: number;
eventType: string;
id?: number;
name: string;
titleTemplate?: string;
userIds: number[];
}) {
return requestClient.post<{ id: number }>(
'/api/service/admin/notice/rule/save',
data,
);
}
export function deleteNoticeRule(data: { id: number }) {
return requestClient.post('/api/service/admin/notice/rule/delete', data);
}
// ---------- 日志 ----------
export interface NoticeLogItem {
id: number;
ruleId: number;
eventType: string;
channelCode: string;
userId: number;
target: string;
title: string;
body: string;
result: number;
error: string;
createdAt: string;
}
export interface NoticeLogListResp {
list: NoticeLogItem[];
total: number;
}
export function getNoticeLogList(data: {
channelCode?: string;
dateFrom?: string;
dateTo?: string;
eventType?: string;
page: number;
result?: number;
size: number;
}) {
return requestClient.post<NoticeLogListResp>(
'/api/service/admin/notice/log/list',
data,
);
}
// ---------- 测试 ----------
export function testNotice(data: {
body?: string;
channelCode?: string;
ruleId?: number;
target?: string;
title?: string;
userIds?: number[];
}) {
return requestClient.post<{ message: string; result: boolean }>(
'/api/service/admin/notice/test',
data,
);
}
// 事件类型选项
export const NOTICE_EVENT_OPTIONS = [
{ label: '任务完成', value: 'job_done' },
{ label: '任务失败', value: 'job_fail' },
{ label: '安全告警', value: 'security_alert' },
{ label: '招聘每日推送', value: 'recruit_daily' },
];

View File

@ -1,11 +1,13 @@
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
// ---------- 管理/api/service/admin/admin ---------- // ---------- 用户管理(/api/service/admin/admin ----------
export interface AdminItem { export interface AdminItem {
id: number; id: number;
username: string; username: string;
nickname: string; nickname: string;
status: number; status: number;
barkDeviceId: string;
pushplusToken: string;
roleIds: number[]; roleIds: number[];
roleNames: string[]; roleNames: string[];
createdAt: string; createdAt: string;
@ -28,8 +30,10 @@ export function getAdminList(data: {
} }
export function createAdmin(data: { export function createAdmin(data: {
barkDeviceId?: string;
nickname: string; nickname: string;
password: string; password: string;
pushplusToken?: string;
roleIds: number[]; roleIds: number[];
username: string; username: string;
}) { }) {
@ -41,7 +45,13 @@ export function createAdmin(data: {
export function updateAdmin( export function updateAdmin(
id: number, id: number,
data: { nickname: string; roleIds: number[]; status: number; }, data: {
barkDeviceId?: string;
nickname: string;
pushplusToken?: string;
roleIds: number[];
status: number;
},
) { ) {
return requestClient.post(`/api/service/admin/admin/update/${id}`, data); return requestClient.post(`/api/service/admin/admin/update/${id}`, data);
} }

View File

@ -19,15 +19,15 @@ import {
updateAdmin, updateAdmin,
} from '#/api/system'; } from '#/api/system';
// Vben Vxe Table + Vben TableAction // Vben Vxe Table + Vben TableAction
const { hasAccessByCodes } = useAccess(); const { hasAccessByCodes } = useAccess();
const roleOptions = ref<{ label: string; value: number }[]>([]); const roleOptions = ref<{ label: string; value: number }[]>([]);
const editing = ref<AdminItem | null>(null); const editing = ref<AdminItem | null>(null);
// /vben // /vben
const [Drawer, drawerApi] = useVbenDrawer({ const [Drawer, drawerApi] = useVbenDrawer({
title: '新增人员', title: '新增用户',
onConfirm: submit, onConfirm: submit,
}); });
@ -38,6 +38,8 @@ const form = reactive({
password: '', password: '',
nickname: '', nickname: '',
status: 1, status: 1,
barkDeviceId: '',
pushplusToken: '',
roleIds: [] as number[], roleIds: [] as number[],
}); });
@ -56,9 +58,11 @@ function openCreate() {
password: '', password: '',
nickname: '', nickname: '',
status: 1, status: 1,
barkDeviceId: '',
pushplusToken: '',
roleIds: [], roleIds: [],
}); });
drawerApi.setState({ title: '新增人员' }); drawerApi.setState({ title: '新增用户' });
drawerApi.open(); drawerApi.open();
} }
@ -69,9 +73,11 @@ function openEdit(row: AdminItem) {
password: '', password: '',
nickname: row.nickname, nickname: row.nickname,
status: row.status, status: row.status,
barkDeviceId: row.barkDeviceId ?? '',
pushplusToken: row.pushplusToken ?? '',
roleIds: row.roleIds ?? [], roleIds: row.roleIds ?? [],
}); });
drawerApi.setState({ title: '编辑人员' }); drawerApi.setState({ title: '编辑用户' });
drawerApi.open(); drawerApi.open();
} }
@ -81,12 +87,16 @@ async function submit() {
? await updateAdmin(editing.value.id, { ? await updateAdmin(editing.value.id, {
nickname: form.nickname, nickname: form.nickname,
status: form.status, status: form.status,
barkDeviceId: form.barkDeviceId,
pushplusToken: form.pushplusToken,
roleIds: form.roleIds, roleIds: form.roleIds,
}) })
: await createAdmin({ : await createAdmin({
username: form.username, username: form.username,
password: form.password, password: form.password,
nickname: form.nickname, nickname: form.nickname,
barkDeviceId: form.barkDeviceId,
pushplusToken: form.pushplusToken,
roleIds: form.roleIds, roleIds: form.roleIds,
}); });
MessagePlugin.success('保存成功'); MessagePlugin.success('保存成功');
@ -94,10 +104,10 @@ async function submit() {
gridApi.query(); gridApi.query();
} }
/** 删除人员(保留原确认弹窗行为) */ /** 删除用户(保留原确认弹窗行为) */
function onDelete(row: AdminItem) { function onDelete(row: AdminItem) {
const d = DialogPlugin.confirm({ const d = DialogPlugin.confirm({
header: '确认删除该人员', header: '确认删除该用户',
onConfirm: async () => { onConfirm: async () => {
await deleteAdmin(row.id); await deleteAdmin(row.id);
MessagePlugin.success('删除成功'); MessagePlugin.success('删除成功');
@ -131,6 +141,18 @@ const gridOptions: VxeTableGridOptions = {
{ field: 'id', title: 'ID', width: 70 }, { field: 'id', title: 'ID', width: 70 },
{ field: 'username', title: '用户名' }, { field: 'username', title: '用户名' },
{ field: 'nickname', title: '昵称' }, { field: 'nickname', title: '昵称' },
{
field: 'barkDeviceId',
title: 'Bark设备',
width: 120,
showOverflow: 'tooltip',
},
{
field: 'pushplusToken',
title: 'pushplus',
width: 120,
showOverflow: 'tooltip',
},
{ {
field: 'statusText', field: 'statusText',
title: '状态', title: '状态',
@ -222,8 +244,8 @@ onMounted(loadRoles);
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<div class="wb-page-head"> <div class="wb-page-head">
<h2 class="wb-page-title">人员管理</h2> <h2 class="wb-page-title">用户管理</h2>
<p class="wb-page-sub">管理系统账号昵称状态与角色归属</p> <p class="wb-page-sub">管理系统账号昵称状态角色与通知渠道Bark / pushplus</p>
</div> </div>
<t-card :bordered="false" class="wb-filter-card"> <t-card :bordered="false" class="wb-filter-card">
@ -243,17 +265,17 @@ onMounted(loadRoles);
theme="primary" theme="primary"
@click="openCreate" @click="openCreate"
> >
新增人员 新增用户
</t-button> </t-button>
</div> </div>
</t-card> </t-card>
<!-- 人员列表Vben Vxe Table + TableAction --> <!-- 用户列表Vben Vxe Table + TableAction -->
<t-card :bordered="false" class="wb-table-card"> <t-card :bordered="false" class="wb-table-card">
<Grid /> <Grid />
</t-card> </t-card>
<!-- 新增/编辑人员抽屉vben 封装抽屉组件 --> <!-- 新增/编辑用户抽屉vben 封装抽屉组件 -->
<Drawer> <Drawer>
<t-form label-align="right" label-width="80px"> <t-form label-align="right" label-width="80px">
<t-form-item label="用户名" required> <t-form-item label="用户名" required>
@ -273,6 +295,18 @@ onMounted(loadRoles);
<t-form-item label="昵称"> <t-form-item label="昵称">
<t-input v-model="form.nickname" placeholder="显示名称" /> <t-input v-model="form.nickname" placeholder="显示名称" />
</t-form-item> </t-form-item>
<t-form-item label="Bark设备">
<t-input
v-model="form.barkDeviceId"
placeholder="Bark 设备ID形如 xxx/… 路径 key"
/>
</t-form-item>
<t-form-item label="pushplus">
<t-input
v-model="form.pushplusToken"
placeholder="pushplus token"
/>
</t-form-item>
<t-form-item label="状态"> <t-form-item label="状态">
<t-switch v-model="form.status" :custom-value="[1, 0]" /> <t-switch v-model="form.status" :custom-value="[1, 0]" />
</t-form-item> </t-form-item>

View File

@ -0,0 +1,182 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h, onMounted, reactive, ref } from 'vue';
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getAutoJobList,
saveAutoJob,
triggerAutoJob,
type AutoJobItem,
} from '#/api/autoJob';
// DB cron //
const editing = ref<AutoJobItem | null>(null);
const [Drawer, drawerApi] = useVbenDrawer({
title: '编辑任务',
onConfirm: submit,
});
const form = reactive({
cronExpr: '',
remark: '',
});
function openEdit(row: AutoJobItem) {
editing.value = row;
Object.assign(form, { cronExpr: row.cronExpr, remark: row.remark });
drawerApi.setState({ title: `编辑「${row.name}` });
drawerApi.open();
}
async function submit() {
if (!editing.value) return;
await saveAutoJob({
id: editing.value.id,
cronExpr: form.cronExpr,
remark: form.remark,
});
MessagePlugin.success('保存成功,调度已更新');
drawerApi.close();
loadList();
}
/** 启停切换(直接生效,动态更新调度) */
async function onToggle(row: AutoJobItem, enabled: number) {
await saveAutoJob({ id: row.id, enabled });
MessagePlugin.success(enabled === 1 ? '任务已启用' : '任务已停用');
loadList();
}
/** 手动触发一次 */
function onTrigger(row: AutoJobItem) {
const d = DialogPlugin.confirm({
header: `立即执行「${row.name}」?`,
body: '将立即运行一次并记录日志',
onConfirm: async () => {
const r = await triggerAutoJob({ id: row.id });
MessagePlugin[r.ok ? 'success' : 'error'](
r.ok ? `执行完成:${r.summary || '成功'}` : `执行失败:${r.summary}`,
);
d.destroy();
loadList();
},
});
}
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'name', title: '任务名称', width: 160 },
{ field: 'code', title: '编码', width: 180, showOverflow: 'tooltip' },
{ field: 'cronExpr', title: 'cron 表达式', width: 140 },
{
field: 'enabled',
title: '启用',
width: 90,
slots: {
default: ({ row }: { row: AutoJobItem }) =>
h(
Tag,
{
theme: row.enabled === 1 ? 'success' : 'default',
variant: 'light',
size: 'small',
},
row.enabled === 1 ? '启用' : '停用',
),
},
},
{
field: 'lastResultText',
title: '上次结果',
width: 100,
slots: {
default: ({ row }: { row: AutoJobItem }) => {
if (row.lastResult === 0) return h('span', '-');
return h(
Tag,
{
theme: row.lastResult === 1 ? 'success' : 'danger',
variant: 'light',
size: 'small',
},
row.lastResult === 1 ? '成功' : '失败',
);
},
},
},
{ field: 'lastRunAt', title: '上次运行', width: 165 },
{ field: 'lastError', title: '上次错误', minWidth: 160, showOverflow: 'tooltip' },
{
field: 'op',
title: '操作',
width: 200,
fixed: 'right',
slots: {
default: ({ row }: { row: AutoJobItem }) =>
h(VbenTableAction, {
actions: [
{
text: row.enabled === 1 ? '停用' : '启用',
onClick: () => onToggle(row, row.enabled === 1 ? 0 : 1),
},
{ text: '执行', onClick: () => onTrigger(row) },
{ text: '编辑', onClick: () => openEdit(row) },
],
}),
},
},
],
proxyConfig: { enabled: false },
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
async function loadList() {
const data = await getAutoJobList();
gridApi.setGridOptions({ data: data.list ?? [] });
}
onMounted(loadList);
</script>
<template>
<Page auto-content-height>
<div class="wb-page-head">
<h2 class="wb-page-title">自动任务</h2>
<p class="wb-page-sub">
管理定时任务招聘抓取/推送等修改 cron启停手动执行执行结果自动记录并可关联通知
</p>
</div>
<t-card :bordered="false" class="wb-filter-card">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500">
cron 表达式为 6 段式 0 0 3 * * *
</div>
<t-button theme="primary" @click="loadList">刷新</t-button>
</div>
</t-card>
<t-card :bordered="false" class="wb-table-card">
<Grid />
</t-card>
<Drawer>
<t-form label-align="right" label-width="90px">
<t-form-item label="cron 表达式" required>
<t-input v-model="form.cronExpr" placeholder="0 0 3 * * *" />
</t-form-item>
<t-form-item label="备注">
<t-input v-model="form.remark" />
</t-form-item>
</t-form>
</Drawer>
</Page>
</template>

View File

@ -0,0 +1,114 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getAutoJobList,
getAutoJobLogList,
type AutoJobItem,
type AutoJobLogItem,
} from '#/api/autoJob';
// /
const jobOptions = ref<{ label: string; value: number }[]>([]);
const query = reactive({ jobId: 0 as number | undefined });
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'runAt', title: '运行时间', width: 165 },
{
field: 'jobName',
title: '任务',
width: 160,
slots: {
default: ({ row }: { row: AutoJobLogItem }) =>
h('span', row.jobName || `#${row.jobId}`),
},
},
{
field: 'result',
title: '结果',
width: 90,
slots: {
default: ({ row }: { row: AutoJobLogItem }) =>
h(
Tag,
{
theme: row.result === 1 ? 'success' : 'danger',
variant: 'light',
size: 'small',
},
row.result === 1 ? '成功' : '失败',
),
},
},
{ field: 'durationMs', title: '耗时(ms)', width: 100 },
{ field: 'summary', title: '摘要', minWidth: 220, showOverflow: 'tooltip' },
{ field: 'error', title: '错误', minWidth: 160, showOverflow: 'tooltip' },
],
pagerConfig: { pageSize: 20 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getAutoJobLogList({
page: page.currentPage,
size: page.pageSize,
jobId: query.jobId || undefined,
});
return { items: data.list ?? [], total: data.total ?? 0 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
function search() {
gridApi.query();
}
async function loadJobs() {
const data = await getAutoJobList();
jobOptions.value = (data.list ?? []).map((j: AutoJobItem) => ({
label: j.name,
value: j.id,
}));
}
onMounted(loadJobs);
</script>
<template>
<Page auto-content-height>
<div class="wb-page-head">
<h2 class="wb-page-title">任务日志</h2>
<p class="wb-page-sub">自动任务每次运行的摘要结果与耗时</p>
</div>
<div class="mb-3 flex flex-wrap items-center gap-2">
<t-select
v-model="query.jobId"
clearable
:options="jobOptions"
placeholder="按任务筛选"
style="width: 200px"
/>
<t-button theme="primary" @click="search">查询</t-button>
</div>
<t-card :bordered="false" class="wb-table-card">
<Grid />
</t-card>
</Page>
</template>

View File

@ -0,0 +1,152 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h, onMounted, reactive, ref } from 'vue';
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
import { MessagePlugin, Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getNoticeChannelList,
saveNoticeChannel,
type NoticeChannelItem,
} from '#/api/notice';
// Bark / pushplus
const editing = ref<NoticeChannelItem | null>(null);
const [Drawer, drawerApi] = useVbenDrawer({
title: '编辑渠道',
onConfirm: submit,
});
const form = reactive({
name: '',
code: '',
enabled: 1,
config: '',
remark: '',
});
function openEdit(row: NoticeChannelItem) {
editing.value = row;
Object.assign(form, {
name: row.name,
code: row.code,
enabled: row.enabled,
config: row.config ?? '',
remark: row.remark ?? '',
});
drawerApi.setState({ title: `编辑渠道「${row.name}` });
drawerApi.open();
}
async function submit() {
if (!editing.value) return;
await saveNoticeChannel({ id: editing.value.id, ...form });
MessagePlugin.success('保存成功');
drawerApi.close();
loadList();
}
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'code', title: '编码', width: 120 },
{ field: 'name', title: '名称', width: 150 },
{
field: 'enabled',
title: '启用',
width: 90,
slots: {
default: ({ row }: { row: NoticeChannelItem }) =>
h(
Tag,
{
theme: row.enabled === 1 ? 'success' : 'default',
variant: 'light',
size: 'small',
},
row.enabled === 1 ? '启用' : '停用',
),
},
},
{
field: 'config',
title: '配置(JSON)',
minWidth: 220,
showOverflow: 'tooltip',
},
{ field: 'remark', title: '备注', minWidth: 180, showOverflow: 'tooltip' },
{
field: 'op',
title: '操作',
width: 100,
fixed: 'right',
slots: {
default: ({ row }: { row: NoticeChannelItem }) =>
h(VbenTableAction, {
actions: [{ text: '编辑', onClick: () => openEdit(row) }],
}),
},
},
],
proxyConfig: { enabled: false },
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
async function loadList() {
const data = await getNoticeChannelList();
gridApi.setGridOptions({ data: data.list ?? [] });
}
onMounted(loadList);
</script>
<template>
<Page auto-content-height>
<div class="wb-page-head">
<h2 class="wb-page-title">通知渠道</h2>
<p class="wb-page-sub">管理推送渠道的启用状态与配置Bark 服务地址等</p>
</div>
<t-card :bordered="false" class="wb-filter-card">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-500">
停用渠道后所有通知规则将不再通过该渠道发送
</div>
<t-button theme="primary" @click="loadList">刷新</t-button>
</div>
</t-card>
<t-card :bordered="false" class="wb-table-card">
<Grid />
</t-card>
<Drawer>
<t-form label-align="right" label-width="90px">
<t-form-item label="编码" required>
<t-input v-model="form.code" disabled placeholder="bark / pushplus" />
</t-form-item>
<t-form-item label="名称" required>
<t-input v-model="form.name" />
</t-form-item>
<t-form-item label="启用">
<t-switch v-model="form.enabled" :custom-value="[1, 0]" />
</t-form-item>
<t-form-item label="配置(JSON)">
<t-textarea
v-model="form.config"
:autosize="{ minRows: 2, maxRows: 4 }"
placeholder='如 Bark{"baseUrl":"http://bark:8080"}'
/>
</t-form-item>
<t-form-item label="备注">
<t-input v-model="form.remark" />
</t-form-item>
</t-form>
</Drawer>
</Page>
</template>

View File

@ -0,0 +1,150 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h, reactive } from 'vue';
import { Page } from '@vben/common-ui';
import { Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getNoticeLogList,
type NoticeLogItem,
} from '#/api/notice';
// //
const query = reactive({
eventType: '',
channelCode: '',
result: 0,
dateRange: [] as string[],
});
function rangeToParam(): { dateFrom?: string; dateTo?: string } {
if (query.dateRange.length === 2 && query.dateRange[0] && query.dateRange[1]) {
return {
dateFrom: `${query.dateRange[0]} 00:00:00`,
dateTo: `${query.dateRange[1]} 23:59:59`,
};
}
return {};
}
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'createdAt', title: '时间', width: 165 },
{ field: 'eventType', title: '事件', width: 110 },
{ field: 'channelCode', title: '渠道', width: 100 },
{ field: 'target', title: '目标', width: 140, showOverflow: 'tooltip' },
{ field: 'title', title: '标题', width: 180, showOverflow: 'tooltip' },
{ field: 'body', title: '内容', minWidth: 200, showOverflow: 'tooltip' },
{
field: 'result',
title: '结果',
width: 90,
slots: {
default: ({ row }: { row: NoticeLogItem }) =>
h(
Tag,
{
theme: row.result === 1 ? 'success' : 'danger',
variant: 'light',
size: 'small',
},
row.result === 1 ? '成功' : '失败',
),
},
},
{ field: 'error', title: '错误', width: 160, showOverflow: 'tooltip' },
],
pagerConfig: { pageSize: 20 },
proxyConfig: {
ajax: {
query: async ({
page,
}: {
page: { currentPage: number; pageSize: number };
}) => {
const data = await getNoticeLogList({
page: page.currentPage,
size: page.pageSize,
eventType: query.eventType || undefined,
channelCode: query.channelCode || undefined,
result: query.result || undefined,
...rangeToParam(),
});
return { items: data.list ?? [], total: data.total ?? 0 };
},
},
},
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
function search() {
gridApi.query();
}
function reset() {
query.eventType = '';
query.channelCode = '';
query.result = 0;
query.dateRange = [];
gridApi.reload();
}
</script>
<template>
<Page auto-content-height>
<div class="wb-page-head">
<h2 class="wb-page-title">通知日志</h2>
<p class="wb-page-sub">每条通知的发送记录目标渠道结果与错误信息</p>
</div>
<div class="mb-3 flex flex-wrap items-center gap-2">
<t-select
v-model="query.eventType"
clearable
:options="[
{ label: '任务完成', value: 'job_done' },
{ label: '任务失败', value: 'job_fail' },
{ label: '安全告警', value: 'security_alert' },
{ label: '测试', value: 'test' },
]"
placeholder="事件类型"
style="width: 140px"
/>
<t-input
v-model="query.channelCode"
clearable
placeholder="渠道(bark/pushplus)"
style="width: 150px"
@enter="search"
/>
<t-select
v-model="query.result"
clearable
:options="[
{ label: '成功', value: 1 },
{ label: '失败', value: 2 },
]"
placeholder="结果"
style="width: 110px"
/>
<t-date-range-picker
v-model="query.dateRange"
clearable
placeholder="时间范围"
style="width: 260px"
/>
<t-button theme="primary" @click="search">查询</t-button>
<t-button variant="outline" @click="reset">重置</t-button>
</div>
<t-card :bordered="false" class="wb-table-card">
<Grid />
</t-card>
</Page>
</template>

View File

@ -0,0 +1,298 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { h, onMounted, reactive, ref } from 'vue';
import { Page, useVbenDrawer, VbenTableAction } from '@vben/common-ui';
import { DialogPlugin, MessagePlugin, Tag } from 'tdesign-vue-next';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteNoticeRule,
getNoticeChannelList,
getNoticeRuleList,
NOTICE_EVENT_OPTIONS,
saveNoticeRule,
testNotice,
type NoticeChannelItem,
type NoticeRuleItem,
} from '#/api/notice';
import { getAdminList } from '#/api/system';
// ""
const channelOptions = ref<{ label: string; value: string }[]>([]);
const userOptions = ref<{ label: string; value: number }[]>([]);
const editing = ref<NoticeRuleItem | null>(null);
const [Drawer, drawerApi] = useVbenDrawer({
title: '新增规则',
onConfirm: submit,
});
const form = reactive({
name: '',
eventType: '',
channelCodes: [] as string[],
userIds: [] as number[],
titleTemplate: '',
bodyTemplate: '',
enabled: 1,
});
async function loadBase() {
const ch = await getNoticeChannelList();
channelOptions.value = (ch.list ?? []).map((c: NoticeChannelItem) => ({
label: `${c.name}${c.enabled === 1 ? '' : '(停用)'}`,
value: c.code,
}));
const us = await getAdminList({ page: 1, size: 100 });
userOptions.value = (us.list ?? []).map((u) => ({
label: `${u.nickname || u.username}(${u.username})`,
value: u.id,
}));
}
function openCreate() {
editing.value = null;
Object.assign(form, {
name: '',
eventType: '',
channelCodes: [],
userIds: [],
titleTemplate: '',
bodyTemplate: '',
enabled: 1,
});
drawerApi.setState({ title: '新增规则' });
drawerApi.open();
}
function openEdit(row: NoticeRuleItem) {
editing.value = row;
Object.assign(form, {
name: row.name,
eventType: row.eventType,
channelCodes: row.channelCodes ?? [],
userIds: row.userIds ?? [],
titleTemplate: row.titleTemplate ?? '',
bodyTemplate: row.bodyTemplate ?? '',
enabled: row.enabled,
});
drawerApi.setState({ title: '编辑规则' });
drawerApi.open();
}
async function submit() {
editing.value
? await saveNoticeRule({ ...form, id: editing.value.id })
: await saveNoticeRule(form);
MessagePlugin.success('保存成功');
drawerApi.close();
gridApi.query();
}
function onDelete(row: NoticeRuleItem) {
const d = DialogPlugin.confirm({
header: `确认删除规则「${row.name}」?`,
onConfirm: async () => {
await deleteNoticeRule({ id: row.id });
MessagePlugin.success('删除成功');
gridApi.query();
d.destroy();
},
});
}
/** 测试该规则:输入标题/内容发送一条 */
function onTest(row: NoticeRuleItem) {
let title = '';
let body = '';
const d = DialogPlugin.confirm({
header: `测试规则「${row.name}`,
body: () =>
h('div', { class: 'flex flex-col gap-2' }, [
h('input', {
class: 't-input__inner',
placeholder: '标题(留空用模板默认)',
style: 'padding:6px;border:1px solid #dcdcdc;border-radius:4px',
onInput: (e: Event) => (title = (e.target as HTMLInputElement).value),
}),
h('input', {
class: 't-input__inner',
placeholder: '内容(留空用模板默认)',
style: 'padding:6px;border:1px solid #dcdcdc;border-radius:4px;margin-top:6px',
onInput: (e: Event) => (body = (e.target as HTMLInputElement).value),
}),
]),
onConfirm: async () => {
const r = await testNotice({
ruleId: row.id,
title: title || undefined,
body: body || undefined,
});
MessagePlugin[r.result ? 'success' : 'error'](
r.result ? `测试发送成功:${r.message}` : `发送失败:${r.message}`,
);
d.destroy();
gridApi.query();
},
});
}
const gridOptions: VxeTableGridOptions = {
columns: [
{ type: 'seq', width: 50, title: '#' },
{ field: 'name', title: '规则名称', width: 140 },
{ field: 'eventType', title: '事件类型', width: 120 },
{
field: 'channelCodesText',
title: '渠道',
width: 160,
slots: {
default: ({ row }: { row: NoticeRuleItem }) =>
h(
'span',
(row.channelCodes ?? []).map((c) => c.toUpperCase()).join(' / ') || '-',
),
},
},
{
field: 'userIdsText',
title: '接收用户',
width: 120,
slots: {
default: ({ row }: { row: NoticeRuleItem }) =>
h('span', `ID: ${(row.userIds ?? []).join(',')}` || '-'),
},
},
{
field: 'enabled',
title: '启用',
width: 80,
slots: {
default: ({ row }: { row: NoticeRuleItem }) =>
h(
Tag,
{
theme: row.enabled === 1 ? 'success' : 'default',
variant: 'light',
size: 'small',
},
row.enabled === 1 ? '启用' : '停用',
),
},
},
{
field: 'op',
title: '操作',
width: 180,
fixed: 'right',
slots: {
default: ({ row }: { row: NoticeRuleItem }) =>
h(VbenTableAction, {
actions: [
{ text: '编辑', onClick: () => openEdit(row) },
{ text: '测试', onClick: () => onTest(row) },
{
text: '删除',
danger: true,
onClick: () => onDelete(row),
},
],
}),
},
},
],
proxyConfig: { enabled: false }, // onMounted
};
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions });
async function loadList() {
const data = await getNoticeRuleList({});
gridApi.setGridOptions({
data: data.list ?? [],
});
}
function search() {
loadList();
}
onMounted(async () => {
await loadBase();
await loadList();
});
</script>
<template>
<Page auto-content-height>
<div class="wb-page-head">
<h2 class="wb-page-title">通知规则</h2>
<p class="wb-page-sub">
定义事件类型 通知渠道 接收用户 标题/内容模板任务完成/失败安全告警等均走这里统一下发
</p>
</div>
<t-card :bordered="false" class="wb-filter-card">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<t-button theme="primary" @click="search">刷新</t-button>
</div>
<t-button theme="primary" @click="openCreate">新增规则</t-button>
</div>
</t-card>
<t-card :bordered="false" class="wb-table-card">
<Grid />
</t-card>
<Drawer>
<t-form label-align="right" label-width="90px">
<t-form-item label="规则名称" required>
<t-input v-model="form.name" placeholder="如:任务失败告警" />
</t-form-item>
<t-form-item label="事件类型" required>
<t-select
v-model="form.eventType"
:options="NOTICE_EVENT_OPTIONS"
placeholder="选择事件类型"
/>
</t-form-item>
<t-form-item label="通知渠道" required>
<t-select
v-model="form.channelCodes"
multiple
:options="channelOptions"
placeholder="选择渠道Bark/pushplus"
/>
</t-form-item>
<t-form-item label="接收用户" required>
<t-select
v-model="form.userIds"
multiple
:options="userOptions"
placeholder="选择接收用户"
/>
</t-form-item>
<t-form-item label="标题模板">
<t-input
v-model="form.titleTemplate"
placeholder="支持 {jobName} {summary} {error} 等占位符"
/>
</t-form-item>
<t-form-item label="内容模板">
<t-textarea
v-model="form.bodyTemplate"
:autosize="{ minRows: 3, maxRows: 6 }"
placeholder="支持 {jobName} {summary} {error}"
/>
</t-form-item>
<t-form-item label="启用">
<t-switch v-model="form.enabled" :custom-value="[1, 0]" />
</t-form-item>
</t-form>
</Drawer>
</Page>
</template>