Files
smart_grid_iot/client/src/views/system/notice/index.vue
T
andyesfly 6cf6a3d5b9 feat: 新增可配置通知中心,支持扫描式/埋点式通知规则与手动群发
新增通知规则实体、扫描任务、API与管理界面,重构站内通知支持用户/电工双类型,移除硬编码的证照到期提醒,新增工单派单、电工审核结果等通知触发场景,调整菜单与数据库结构适配新通知体系。
2026-10-06 11:53:02 +08:00

1123 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>