新增通知规则实体、扫描任务、API与管理界面,重构站内通知支持用户/电工双类型,移除硬编码的证照到期提醒,新增工单派单、电工审核结果等通知触发场景,调整菜单与数据库结构适配新通知体系。
1123 lines
28 KiB
Vue
1123 lines
28 KiB
Vue
<template>
|
||
<div class="notice-page">
|
||
<!-- 顶部标题 -->
|
||
<div class="page-header">
|
||
<div class="breadcrumb">
|
||
<span>系统管理</span>
|
||
<span class="sep">/</span>
|
||
<span class="cur">通知管理</span>
|
||
</div>
|
||
<div class="header-main">
|
||
<div class="header-left">
|
||
<div class="header-row">
|
||
<span class="title-bar"></span>
|
||
<span class="main-title">通知管理</span>
|
||
<span class="sub-title">NOTICE CENTER</span>
|
||
</div>
|
||
<p class="desc">配置各类通知事件的触发规则与接收对象,支持手动群发消息并追踪发送记录。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="content-card">
|
||
<a-tabs v-model:active-key="activeTab" class="notice-tabs" @change="handleTabChange">
|
||
<!-- ============ 页签1:通知规则 ============ -->
|
||
<a-tab-pane key="rules" tab="通知规则">
|
||
<div class="tab-body">
|
||
<div class="filter-card">
|
||
<div class="filter-grid">
|
||
<div class="filter-item">
|
||
<label class="filter-label">关键词检索</label>
|
||
<a-input
|
||
v-model:value="ruleQuery.keyword"
|
||
placeholder="规则键 / 规则名称"
|
||
allow-clear
|
||
maxlength="64"
|
||
@pressEnter="handleRuleSearch"
|
||
>
|
||
<template #prefix><SearchOutlined /></template>
|
||
</a-input>
|
||
</div>
|
||
<div class="filter-item">
|
||
<label class="filter-label">事件</label>
|
||
<a-select
|
||
v-model:value="ruleQuery.eventKey"
|
||
placeholder="全部事件"
|
||
allow-clear
|
||
:options="eventFilterOptions"
|
||
style="width: 100%"
|
||
/>
|
||
</div>
|
||
<div class="filter-item">
|
||
<label class="filter-label">状态</label>
|
||
<a-select v-model:value="ruleQuery.enabled" placeholder="全部状态" allow-clear style="width: 100%">
|
||
<a-select-option :value="1">启用</a-select-option>
|
||
<a-select-option :value="0">停用</a-select-option>
|
||
</a-select>
|
||
</div>
|
||
</div>
|
||
<div class="filter-bottom">
|
||
<div class="filter-actions">
|
||
<a-button type="primary" @click="handleRuleSearch">
|
||
<template #icon><SearchOutlined /></template>
|
||
查询
|
||
</a-button>
|
||
<a-button @click="handleRuleReset">
|
||
<template #icon><ReloadOutlined /></template>
|
||
重置
|
||
</a-button>
|
||
</div>
|
||
<div class="filter-actions">
|
||
<a-button :loading="scanLoading" @click="handleRunScan">
|
||
<template #icon><ThunderboltOutlined /></template>
|
||
立即执行扫描
|
||
</a-button>
|
||
<a-button type="primary" @click="handleAddRule">
|
||
<template #icon><PlusOutlined /></template>
|
||
新增规则
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="list-section">
|
||
<div class="list-header">
|
||
<div class="list-title">
|
||
<span class="dot"></span>
|
||
<span class="t">规则清单</span>
|
||
<span class="count">{{ ruleTotal }}</span>
|
||
</div>
|
||
<a-button type="text" size="small" title="刷新列表" @click="fetchRules">
|
||
<template #icon><ReloadOutlined /></template>
|
||
</a-button>
|
||
</div>
|
||
|
||
<a-table
|
||
:columns="ruleColumns"
|
||
:data-source="ruleList"
|
||
:loading="ruleLoading"
|
||
:pagination="rulePagination"
|
||
row-key="id"
|
||
:scroll="{ x: 'max-content' }"
|
||
@change="handleRuleTableChange"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'ruleName'">
|
||
<div class="cell-main">
|
||
<span class="rule-name">{{ record.ruleName }}</span>
|
||
<span class="rule-key mono">{{ record.ruleKey }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'eventKey'">
|
||
{{ eventNameMap[record.eventKey] || record.eventKey }}
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'triggerMode'">
|
||
<a-tag :color="record.triggerMode === 1 ? 'blue' : 'purple'">
|
||
{{ record.triggerMode === 1 ? '扫描式' : '埋点式' }}
|
||
</a-tag>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'advanceDays'">
|
||
{{ record.triggerMode === 1 ? `${record.advanceDays ?? 0} 天` : '-' }}
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'enabled'">
|
||
<a-switch
|
||
:checked="record.enabled === 1"
|
||
checked-children="启用"
|
||
un-checked-children="停用"
|
||
@change="(checked: boolean) => handleRuleEnabledChange(record, checked)"
|
||
/>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'receiver'">
|
||
<span class="receiver-summary">{{ summarizeReceiver(record.receiverConfig) }}</span>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'action'">
|
||
<a-space>
|
||
<a-button type="link" size="small" @click="handleEditRule(record)">
|
||
<template #icon><EditOutlined /></template>
|
||
编辑
|
||
</a-button>
|
||
<a-popconfirm
|
||
title="确定要删除该规则吗?"
|
||
ok-text="确定"
|
||
cancel-text="取消"
|
||
ok-button-props="{ danger: true }"
|
||
@confirm="handleDeleteRule(record.id)"
|
||
>
|
||
<a-button type="link" size="small" danger>
|
||
<template #icon><DeleteOutlined /></template>
|
||
删除
|
||
</a-button>
|
||
</a-popconfirm>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
|
||
<template #emptyText>
|
||
<a-empty description="暂无通知规则" />
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</div>
|
||
</a-tab-pane>
|
||
|
||
<!-- ============ 页签2:手动群发 ============ -->
|
||
<a-tab-pane key="batch" tab="手动群发">
|
||
<div class="tab-body">
|
||
<div class="batch-panel">
|
||
<a-form :model="batchForm" :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
|
||
<a-form-item label="消息标题" required>
|
||
<a-input
|
||
v-model:value="batchForm.title"
|
||
placeholder="请输入消息标题"
|
||
maxlength="128"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="消息正文" required>
|
||
<a-textarea
|
||
v-model:value="batchForm.content"
|
||
:rows="4"
|
||
placeholder="请输入消息正文"
|
||
maxlength="512"
|
||
show-count
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="接收对象" required>
|
||
<ReceiverConfigEditor :config="batchForm.receiverConfig" :allow-event-mode="false" />
|
||
</a-form-item>
|
||
<a-form-item :wrapper-col="{ offset: 4, span: 18 }">
|
||
<a-button type="primary" :loading="batchSending" @click="handleSendBatch">
|
||
<template #icon><SendOutlined /></template>
|
||
立即发送
|
||
</a-button>
|
||
</a-form-item>
|
||
</a-form>
|
||
</div>
|
||
</div>
|
||
</a-tab-pane>
|
||
|
||
<!-- ============ 页签3:发送记录 ============ -->
|
||
<a-tab-pane key="records" tab="发送记录">
|
||
<div class="tab-body">
|
||
<div class="filter-card">
|
||
<div class="filter-grid filter-grid-single">
|
||
<div class="filter-item">
|
||
<label class="filter-label">关键词检索</label>
|
||
<a-input
|
||
v-model:value="batchQuery.keyword"
|
||
placeholder="消息标题 / 正文"
|
||
allow-clear
|
||
maxlength="64"
|
||
@pressEnter="handleBatchSearch"
|
||
>
|
||
<template #prefix><SearchOutlined /></template>
|
||
</a-input>
|
||
</div>
|
||
</div>
|
||
<div class="filter-bottom">
|
||
<div class="filter-actions">
|
||
<a-button type="primary" @click="handleBatchSearch">
|
||
<template #icon><SearchOutlined /></template>
|
||
查询
|
||
</a-button>
|
||
<a-button @click="handleBatchReset">
|
||
<template #icon><ReloadOutlined /></template>
|
||
重置
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="list-section">
|
||
<div class="list-header">
|
||
<div class="list-title">
|
||
<span class="dot"></span>
|
||
<span class="t">群发记录</span>
|
||
<span class="count">{{ batchTotal }}</span>
|
||
</div>
|
||
<a-button type="text" size="small" title="刷新列表" @click="fetchBatches">
|
||
<template #icon><ReloadOutlined /></template>
|
||
</a-button>
|
||
</div>
|
||
|
||
<a-table
|
||
:columns="batchColumns"
|
||
:data-source="batchList"
|
||
:loading="batchLoading"
|
||
:pagination="batchPagination"
|
||
row-key="id"
|
||
:scroll="{ x: 'max-content' }"
|
||
@change="handleBatchTableChange"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'title'">
|
||
<span class="cell-strong">{{ record.title }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'content'">
|
||
{{ record.content }}
|
||
</template>
|
||
<template v-else-if="column.key === 'receiverCount'">
|
||
<a-tag color="green">{{ record.receiverCount }} 人</a-tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'sentByName'">
|
||
{{ record.sentByName || '—' }}
|
||
</template>
|
||
<template v-else-if="column.key === 'sentAt'">
|
||
{{ formatDateTime(record.sentAt || record.created_at) }}
|
||
</template>
|
||
</template>
|
||
|
||
<template #emptyText>
|
||
<a-empty description="暂无发送记录" />
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</div>
|
||
</a-tab-pane>
|
||
</a-tabs>
|
||
</div>
|
||
|
||
<!-- 新增/编辑规则弹窗 -->
|
||
<a-modal
|
||
v-model:open="ruleModalVisible"
|
||
:title="ruleModalTitle"
|
||
:confirm-loading="ruleModalLoading"
|
||
width="760px"
|
||
@ok="handleSubmitRule"
|
||
@cancel="handleCancelRule"
|
||
>
|
||
<a-form
|
||
ref="ruleFormRef"
|
||
:model="ruleForm"
|
||
:rules="ruleFormRules"
|
||
:label-col="{ span: 5 }"
|
||
:wrapper-col="{ span: 18 }"
|
||
>
|
||
<a-form-item label="规则键" name="ruleKey">
|
||
<a-input
|
||
v-model:value="ruleForm.ruleKey"
|
||
placeholder="唯一标识,如 cert_expire_default"
|
||
maxlength="64"
|
||
:disabled="isEdit"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="规则名称" name="ruleName">
|
||
<a-input v-model:value="ruleForm.ruleName" placeholder="请输入规则名称" maxlength="64" />
|
||
</a-form-item>
|
||
<a-form-item label="事件" name="eventKey">
|
||
<a-select
|
||
v-model:value="ruleForm.eventKey"
|
||
placeholder="请选择通知事件"
|
||
allow-clear
|
||
:options="eventSelectOptions"
|
||
style="width: 100%"
|
||
@change="handleEventChange"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="触发方式">
|
||
<a-input :value="currentEvent?.triggerModeText || '—'" disabled />
|
||
</a-form-item>
|
||
<a-form-item v-if="ruleForm.triggerMode === 1" label="提前天数" name="advanceDays">
|
||
<a-input-number
|
||
v-model:value="ruleForm.advanceDays"
|
||
:min="0"
|
||
:max="3650"
|
||
:precision="0"
|
||
style="width: 200px"
|
||
/>
|
||
<span class="form-hint">到期前 N 天开始提醒(含已过期)</span>
|
||
</a-form-item>
|
||
<a-form-item label="标题模板" name="titleTpl">
|
||
<a-input v-model:value="ruleForm.titleTpl" placeholder="支持 {变量} 占位符" maxlength="128" />
|
||
</a-form-item>
|
||
<a-form-item label="正文模板" name="contentTpl">
|
||
<a-textarea
|
||
v-model:value="ruleForm.contentTpl"
|
||
:rows="3"
|
||
placeholder="支持 {变量} 占位符"
|
||
maxlength="512"
|
||
show-count
|
||
/>
|
||
<div v-if="currentVariables.length" class="tpl-vars">
|
||
<span class="tpl-vars-label">可用变量(点击插入正文):</span>
|
||
<a-tag
|
||
v-for="v in currentVariables"
|
||
:key="v.key"
|
||
color="blue"
|
||
class="tpl-var-tag"
|
||
@click="insertVariable(v.key)"
|
||
>
|
||
{{ variableText(v.key) }} {{ v.label }}
|
||
</a-tag>
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="接收对象">
|
||
<ReceiverConfigEditor :config="ruleForm.receiverConfig" />
|
||
</a-form-item>
|
||
<a-form-item label="状态">
|
||
<a-switch
|
||
v-model:checked="ruleForm.enabled"
|
||
:checked-value="1"
|
||
:un-checked-value="0"
|
||
checked-children="启用"
|
||
un-checked-children="停用"
|
||
/>
|
||
</a-form-item>
|
||
<a-form-item label="备注" name="remark">
|
||
<a-textarea v-model:value="ruleForm.remark" :rows="2" placeholder="请输入备注" maxlength="255" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted } from 'vue';
|
||
import { message } from 'ant-design-vue';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
SearchOutlined,
|
||
ReloadOutlined,
|
||
PlusOutlined,
|
||
EditOutlined,
|
||
DeleteOutlined,
|
||
ThunderboltOutlined,
|
||
SendOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
import {
|
||
noticeRuleApi,
|
||
createDefaultReceiverConfig,
|
||
type NoticeRule,
|
||
type NoticeRuleQuery,
|
||
type NoticeEventMeta,
|
||
type ReceiverConfig,
|
||
} from '@/api/noticeRule';
|
||
import { noticeBatchApi, type NoticeBatch, type NoticeBatchQuery } from '@/api/noticeBatch';
|
||
import ReceiverConfigEditor from './ReceiverConfigEditor.vue';
|
||
|
||
const activeTab = ref('rules');
|
||
|
||
// ==================== 接收对象辅助 ====================
|
||
function normalizeReceiver(cfg?: ReceiverConfig | null): ReceiverConfig {
|
||
return {
|
||
users: {
|
||
roles: [...(cfg?.users?.roles ?? [])],
|
||
ids: [...(cfg?.users?.ids ?? [])],
|
||
},
|
||
workers: {
|
||
mode: cfg?.workers?.mode ?? 'none',
|
||
ids: [...(cfg?.workers?.ids ?? [])],
|
||
auditStatus: cfg?.workers?.auditStatus,
|
||
status: cfg?.workers?.status,
|
||
},
|
||
};
|
||
}
|
||
|
||
function summarizeReceiver(cfg?: ReceiverConfig | null): string {
|
||
const parts: string[] = [];
|
||
if (cfg?.users?.roles?.length) parts.push(`角色:${cfg.users.roles.join('、')}`);
|
||
if (cfg?.users?.ids?.length) parts.push(`用户:${cfg.users.ids.length} 人`);
|
||
const mode = cfg?.workers?.mode;
|
||
const modeText: Record<string, string> = {
|
||
event: '事件关联电工',
|
||
all: '全部在岗电工',
|
||
ids: '指定电工',
|
||
status: '按状态筛选',
|
||
};
|
||
if (mode && mode !== 'none') parts.push(`电工:${modeText[mode] || mode}`);
|
||
return parts.length ? parts.join(';') : '未配置';
|
||
}
|
||
|
||
function formatDateTime(value?: string | null): string {
|
||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-';
|
||
}
|
||
|
||
// ==================== 事件字典 ====================
|
||
const events = ref<NoticeEventMeta[]>([]);
|
||
const eventNameMap = computed<Record<string, string>>(() => {
|
||
const map: Record<string, string> = {};
|
||
for (const e of events.value) map[e.eventKey] = e.eventName;
|
||
return map;
|
||
});
|
||
const eventSelectOptions = computed(() =>
|
||
events.value.map((e) => ({ value: e.eventKey, label: `${e.eventName}(${e.triggerModeText})` }))
|
||
);
|
||
const eventFilterOptions = computed(() =>
|
||
events.value.map((e) => ({ value: e.eventKey, label: e.eventName }))
|
||
);
|
||
|
||
async function fetchEvents() {
|
||
try {
|
||
const res = await noticeRuleApi.getEvents();
|
||
if (res.code === 200) events.value = res.data || [];
|
||
} catch (error) {
|
||
// 由请求拦截器统一提示
|
||
}
|
||
}
|
||
|
||
// ==================== 通知规则列表 ====================
|
||
const ruleLoading = ref(false);
|
||
const ruleList = ref<NoticeRule[]>([]);
|
||
const ruleTotal = ref(0);
|
||
const ruleQuery = reactive<NoticeRuleQuery>({
|
||
keyword: '',
|
||
eventKey: undefined,
|
||
enabled: undefined,
|
||
page: 1,
|
||
pageSize: 10,
|
||
});
|
||
|
||
const ruleColumns = [
|
||
{ title: '规则名称', key: 'ruleName', width: 220 },
|
||
{ title: '事件', key: 'eventKey', width: 200, ellipsis: true },
|
||
{ title: '触发方式', key: 'triggerMode', width: 110, align: 'center' as const },
|
||
{ title: '提前天数', key: 'advanceDays', width: 100, align: 'center' as const },
|
||
{ title: '启用', key: 'enabled', width: 100, align: 'center' as const },
|
||
{ title: '接收对象', key: 'receiver', ellipsis: true },
|
||
{ title: '操作', key: 'action', width: 150, align: 'center' as const, fixed: 'right' as const },
|
||
];
|
||
|
||
const rulePagination = computed(() => ({
|
||
current: ruleQuery.page,
|
||
pageSize: ruleQuery.pageSize,
|
||
total: ruleTotal.value,
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
showTotal: (t: number) => `共 ${t} 条数据`,
|
||
}));
|
||
|
||
async function fetchRules() {
|
||
ruleLoading.value = true;
|
||
try {
|
||
const res = await noticeRuleApi.getList({
|
||
keyword: ruleQuery.keyword || undefined,
|
||
eventKey: ruleQuery.eventKey || undefined,
|
||
enabled: ruleQuery.enabled,
|
||
page: ruleQuery.page,
|
||
pageSize: ruleQuery.pageSize,
|
||
});
|
||
if (res.code === 200) {
|
||
ruleList.value = res.data?.list || [];
|
||
ruleTotal.value = res.data?.total || 0;
|
||
}
|
||
} catch (error) {
|
||
// 由请求拦截器统一提示
|
||
} finally {
|
||
ruleLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function handleRuleSearch() {
|
||
ruleQuery.page = 1;
|
||
fetchRules();
|
||
}
|
||
|
||
function handleRuleReset() {
|
||
ruleQuery.keyword = '';
|
||
ruleQuery.eventKey = undefined;
|
||
ruleQuery.enabled = undefined;
|
||
ruleQuery.page = 1;
|
||
fetchRules();
|
||
}
|
||
|
||
function handleRuleTableChange(pag: any) {
|
||
ruleQuery.page = pag.current;
|
||
ruleQuery.pageSize = pag.pageSize;
|
||
fetchRules();
|
||
}
|
||
|
||
async function handleRuleEnabledChange(record: NoticeRule, checked: boolean) {
|
||
const enabled = checked ? 1 : 0;
|
||
try {
|
||
const res = await noticeRuleApi.updateEnabled(record.id, enabled as 0 | 1);
|
||
if (res.code === 200) {
|
||
message.success(enabled === 1 ? '规则已启用' : '规则已停用');
|
||
fetchRules();
|
||
}
|
||
} catch (error) {
|
||
// 由请求拦截器统一提示
|
||
}
|
||
}
|
||
|
||
async function handleDeleteRule(id: number) {
|
||
try {
|
||
const res = await noticeRuleApi.remove(id);
|
||
if (res.code === 200) {
|
||
message.success('删除成功');
|
||
ruleQuery.page = 1;
|
||
fetchRules();
|
||
}
|
||
} catch (error) {
|
||
// 由请求拦截器统一提示
|
||
}
|
||
}
|
||
|
||
// 立即执行扫描
|
||
const scanLoading = ref(false);
|
||
async function handleRunScan() {
|
||
scanLoading.value = true;
|
||
try {
|
||
const res = await noticeRuleApi.runScan();
|
||
if (res.code === 200) {
|
||
message.success(res.data?.summary || '扫描执行完成', 5);
|
||
}
|
||
} catch (error) {
|
||
// 由请求拦截器统一提示
|
||
} finally {
|
||
scanLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 规则弹窗 ====================
|
||
const ruleModalVisible = ref(false);
|
||
const ruleModalLoading = ref(false);
|
||
const isEdit = ref(false);
|
||
const editId = ref<number | null>(null);
|
||
const ruleFormRef = ref();
|
||
|
||
const ruleForm = reactive({
|
||
ruleKey: '',
|
||
ruleName: '',
|
||
eventKey: undefined as string | undefined,
|
||
triggerMode: 1,
|
||
enabled: 1,
|
||
advanceDays: 30 as number | undefined,
|
||
titleTpl: '',
|
||
contentTpl: '',
|
||
receiverConfig: createDefaultReceiverConfig(),
|
||
remark: '',
|
||
});
|
||
|
||
const ruleModalTitle = computed(() => (isEdit.value ? '编辑规则' : '新增规则'));
|
||
|
||
const currentEvent = computed(() => events.value.find((e) => e.eventKey === ruleForm.eventKey));
|
||
const currentVariables = computed(() => currentEvent.value?.variables || []);
|
||
|
||
const ruleFormRules = computed(() => ({
|
||
ruleKey: isEdit.value
|
||
? []
|
||
: [{ required: true, message: '请输入规则键', trigger: 'blur' as const }],
|
||
ruleName: [{ required: true, message: '请输入规则名称', trigger: 'blur' as const }],
|
||
eventKey: [{ required: true, message: '请选择通知事件', trigger: 'change' as const }],
|
||
titleTpl: [{ required: true, message: '请输入标题模板', trigger: 'blur' as const }],
|
||
contentTpl: [{ required: true, message: '请输入正文模板', trigger: 'blur' as const }],
|
||
}));
|
||
|
||
function handleEventChange() {
|
||
ruleForm.triggerMode = currentEvent.value?.triggerMode ?? 1;
|
||
if (ruleForm.triggerMode === 2) ruleForm.advanceDays = undefined;
|
||
else if (ruleForm.advanceDays === undefined) ruleForm.advanceDays = 30;
|
||
}
|
||
|
||
function insertVariable(key: string) {
|
||
ruleForm.contentTpl = `${ruleForm.contentTpl || ''}{${key}}`;
|
||
}
|
||
|
||
function variableText(key: string) {
|
||
return `{${key}}`;
|
||
}
|
||
|
||
function handleAddRule() {
|
||
isEdit.value = false;
|
||
editId.value = null;
|
||
resetRuleForm();
|
||
ruleModalVisible.value = true;
|
||
}
|
||
|
||
function handleEditRule(record: NoticeRule) {
|
||
isEdit.value = true;
|
||
editId.value = record.id;
|
||
ruleForm.ruleKey = record.ruleKey;
|
||
ruleForm.ruleName = record.ruleName;
|
||
ruleForm.eventKey = record.eventKey;
|
||
ruleForm.triggerMode = record.triggerMode;
|
||
ruleForm.enabled = record.enabled;
|
||
ruleForm.advanceDays = record.advanceDays ?? undefined;
|
||
ruleForm.titleTpl = record.titleTpl;
|
||
ruleForm.contentTpl = record.contentTpl;
|
||
ruleForm.receiverConfig = normalizeReceiver(record.receiverConfig);
|
||
ruleForm.remark = record.remark || '';
|
||
ruleFormRef.value?.clearValidate();
|
||
ruleModalVisible.value = true;
|
||
}
|
||
|
||
function resetRuleForm() {
|
||
ruleForm.ruleKey = '';
|
||
ruleForm.ruleName = '';
|
||
ruleForm.eventKey = undefined;
|
||
ruleForm.triggerMode = 1;
|
||
ruleForm.enabled = 1;
|
||
ruleForm.advanceDays = 30;
|
||
ruleForm.titleTpl = '';
|
||
ruleForm.contentTpl = '';
|
||
ruleForm.receiverConfig = createDefaultReceiverConfig();
|
||
ruleForm.remark = '';
|
||
ruleFormRef.value?.resetFields();
|
||
}
|
||
|
||
function handleCancelRule() {
|
||
ruleModalVisible.value = false;
|
||
resetRuleForm();
|
||
}
|
||
|
||
async function handleSubmitRule() {
|
||
try {
|
||
await ruleFormRef.value.validate();
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
ruleModalLoading.value = true;
|
||
try {
|
||
const payload = {
|
||
ruleName: ruleForm.ruleName,
|
||
eventKey: ruleForm.eventKey as string,
|
||
triggerMode: ruleForm.triggerMode,
|
||
enabled: ruleForm.enabled,
|
||
advanceDays: ruleForm.triggerMode === 1 ? Number(ruleForm.advanceDays ?? 0) : undefined,
|
||
titleTpl: ruleForm.titleTpl,
|
||
contentTpl: ruleForm.contentTpl,
|
||
receiverConfig: normalizeReceiver(ruleForm.receiverConfig),
|
||
remark: ruleForm.remark || undefined,
|
||
};
|
||
|
||
let res: any;
|
||
if (isEdit.value && editId.value) {
|
||
res = await noticeRuleApi.update(editId.value, payload);
|
||
} else {
|
||
res = await noticeRuleApi.create({ ...payload, ruleKey: ruleForm.ruleKey });
|
||
}
|
||
|
||
if (res.code === 200) {
|
||
message.success(isEdit.value ? '更新成功' : '新增成功');
|
||
ruleModalVisible.value = false;
|
||
ruleQuery.page = 1;
|
||
fetchRules();
|
||
}
|
||
} catch (error) {
|
||
// 由请求拦截器统一提示
|
||
} finally {
|
||
ruleModalLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 手动群发 ====================
|
||
const batchForm = reactive({
|
||
title: '',
|
||
content: '',
|
||
receiverConfig: createDefaultReceiverConfig(),
|
||
});
|
||
const batchSending = ref(false);
|
||
|
||
function resetBatchForm() {
|
||
batchForm.title = '';
|
||
batchForm.content = '';
|
||
batchForm.receiverConfig = createDefaultReceiverConfig();
|
||
}
|
||
|
||
async function handleSendBatch() {
|
||
if (!batchForm.title.trim()) {
|
||
message.warning('请输入消息标题');
|
||
return;
|
||
}
|
||
if (!batchForm.content.trim()) {
|
||
message.warning('请输入消息正文');
|
||
return;
|
||
}
|
||
|
||
batchSending.value = true;
|
||
try {
|
||
const res = await noticeBatchApi.send({
|
||
title: batchForm.title.trim(),
|
||
content: batchForm.content.trim(),
|
||
receiverConfig: normalizeReceiver(batchForm.receiverConfig),
|
||
});
|
||
if (res.code === 200) {
|
||
message.success(`发送成功,已送达 ${res.data?.receiverCount ?? 0} 人`);
|
||
resetBatchForm();
|
||
fetchBatches();
|
||
}
|
||
} catch (error) {
|
||
// 未匹配到接收人等错误由请求拦截器统一提示
|
||
} finally {
|
||
batchSending.value = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 发送记录 ====================
|
||
const batchLoading = ref(false);
|
||
const batchList = ref<NoticeBatch[]>([]);
|
||
const batchTotal = ref(0);
|
||
const batchLoaded = ref(false);
|
||
const batchQuery = reactive<NoticeBatchQuery>({
|
||
keyword: '',
|
||
page: 1,
|
||
pageSize: 10,
|
||
});
|
||
|
||
const batchColumns = [
|
||
{ title: '标题', key: 'title', width: 220, ellipsis: true },
|
||
{ title: '内容', key: 'content', ellipsis: true },
|
||
{ title: '接收人数', key: 'receiverCount', width: 110, align: 'center' as const },
|
||
{ title: '发送人', key: 'sentByName', width: 130 },
|
||
{ title: '发送时间', key: 'sentAt', width: 180 },
|
||
];
|
||
|
||
const batchPagination = computed(() => ({
|
||
current: batchQuery.page,
|
||
pageSize: batchQuery.pageSize,
|
||
total: batchTotal.value,
|
||
showSizeChanger: true,
|
||
showQuickJumper: true,
|
||
showTotal: (t: number) => `共 ${t} 条数据`,
|
||
}));
|
||
|
||
async function fetchBatches() {
|
||
batchLoading.value = true;
|
||
try {
|
||
const res = await noticeBatchApi.getList({
|
||
keyword: batchQuery.keyword || undefined,
|
||
page: batchQuery.page,
|
||
pageSize: batchQuery.pageSize,
|
||
});
|
||
if (res.code === 200) {
|
||
batchList.value = res.data?.list || [];
|
||
batchTotal.value = res.data?.total || 0;
|
||
batchLoaded.value = true;
|
||
}
|
||
} catch (error) {
|
||
// 由请求拦截器统一提示
|
||
} finally {
|
||
batchLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function handleBatchSearch() {
|
||
batchQuery.page = 1;
|
||
fetchBatches();
|
||
}
|
||
|
||
function handleBatchReset() {
|
||
batchQuery.keyword = '';
|
||
batchQuery.page = 1;
|
||
fetchBatches();
|
||
}
|
||
|
||
function handleBatchTableChange(pag: any) {
|
||
batchQuery.page = pag.current;
|
||
batchQuery.pageSize = pag.pageSize;
|
||
fetchBatches();
|
||
}
|
||
|
||
// 发送记录按需加载,避免无 notice:batch 权限时初始即报错
|
||
function handleTabChange(key: string | number) {
|
||
if (key === 'records' && !batchLoaded.value) fetchBatches();
|
||
}
|
||
|
||
// ==================== 生命周期 ====================
|
||
onMounted(() => {
|
||
fetchEvents();
|
||
fetchRules();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.notice-page {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
padding: 20px 24px;
|
||
box-sizing: border-box;
|
||
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
|
||
}
|
||
|
||
.page-header {
|
||
flex-shrink: 0;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.breadcrumb {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
color: #5b6b7e;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.breadcrumb .sep {
|
||
color: #c2c6d8;
|
||
}
|
||
|
||
.breadcrumb .cur {
|
||
color: #0054cd;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.header-main {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.header-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 12px;
|
||
}
|
||
|
||
.title-bar {
|
||
width: 5px;
|
||
height: 22px;
|
||
background: #0054cd;
|
||
border-radius: 3px;
|
||
align-self: center;
|
||
}
|
||
|
||
.main-title {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
line-height: 1.4;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.sub-title {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: #0054cd;
|
||
letter-spacing: 2px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.desc {
|
||
margin: 6px 0 0;
|
||
font-size: 13px;
|
||
color: #5b6b7e;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.content-card {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #fff;
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
|
||
padding: 8px 20px 20px;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.notice-tabs {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.notice-tabs :deep(.ant-tabs-content-holder) {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.notice-tabs :deep(.ant-tabs-content) {
|
||
height: 100%;
|
||
}
|
||
|
||
.notice-tabs :deep(.ant-tabs-tabpane) {
|
||
height: 100%;
|
||
}
|
||
|
||
.tab-body {
|
||
height: 100%;
|
||
overflow: auto;
|
||
padding-top: 12px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.filter-card {
|
||
background: #f8faff;
|
||
border: 1px solid #dbe4ee;
|
||
border-radius: 10px;
|
||
padding: 16px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.filter-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 12px 16px;
|
||
}
|
||
|
||
.filter-grid-single {
|
||
grid-template-columns: repeat(3, 1fr);
|
||
}
|
||
|
||
.filter-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.filter-label {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #5b6b7e;
|
||
}
|
||
|
||
.filter-bottom {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-top: 14px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid #dbe4ee;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.filter-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.list-section {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.list-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 2px 4px 12px;
|
||
}
|
||
|
||
.list-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.list-title .dot {
|
||
width: 4px;
|
||
height: 16px;
|
||
background: #0054cd;
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.list-title .t {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #0d1c2c;
|
||
}
|
||
|
||
.list-title .count {
|
||
padding: 1px 8px;
|
||
border-radius: 6px;
|
||
background: #eef4ff;
|
||
color: #0054cd;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
font-family: Consolas, Menlo, monospace;
|
||
}
|
||
|
||
.cell-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.rule-name {
|
||
font-weight: 600;
|
||
color: #0d1c2c;
|
||
}
|
||
|
||
.rule-key {
|
||
font-size: 12px;
|
||
color: #8a97a8;
|
||
}
|
||
|
||
.cell-strong {
|
||
font-weight: 600;
|
||
color: #0d1c2c;
|
||
}
|
||
|
||
.receiver-summary {
|
||
font-size: 13px;
|
||
color: #5b6b7e;
|
||
}
|
||
|
||
.mono {
|
||
font-family: Consolas, Menlo, monospace;
|
||
}
|
||
|
||
.form-hint {
|
||
margin-left: 12px;
|
||
font-size: 12px;
|
||
color: #8a97a8;
|
||
}
|
||
|
||
.tpl-vars {
|
||
margin-top: 8px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.tpl-vars-label {
|
||
font-size: 12px;
|
||
color: #8a97a8;
|
||
}
|
||
|
||
.tpl-var-tag {
|
||
cursor: pointer;
|
||
margin-inline-end: 0;
|
||
}
|
||
|
||
.batch-panel {
|
||
max-width: 900px;
|
||
}
|
||
|
||
:deep(.ant-table) {
|
||
font-size: 14px;
|
||
}
|
||
|
||
:deep(.ant-table-thead > tr > th) {
|
||
background: #f4f7ff;
|
||
color: #5b6b7e;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.ant-btn-link) {
|
||
padding: 0 4px;
|
||
}
|
||
|
||
:deep(.ant-btn-primary) {
|
||
background: #0054cd;
|
||
border-color: #0054cd;
|
||
}
|
||
|
||
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
|
||
background: #0947ab;
|
||
border-color: #0947ab;
|
||
}
|
||
|
||
:deep(.ant-modal-title) {
|
||
color: #0d1c2c;
|
||
font-weight: 700;
|
||
}
|
||
</style>
|