feat(filter): 在日志页面顶部集成发件人黑白名单管理面板
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-13 06:02:38 +08:00
parent bbb671c5ab
commit a717183e55
5 changed files with 659 additions and 166 deletions

View File

@ -1,16 +1,26 @@
.log-settings {
.log-settings-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
overflow-y: auto;
box-sizing: border-box;
}
.log-settings {
min-height: 480px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 10px 4px 2px;
box-sizing: border-box;
flex: 1;
}
/* 只为日志页建立确定高度,滚动限制在表格内,不改变其他设置页。 */
:global(.settings-tabs .el-tab-pane:has(> .log-settings)) {
/* 只为日志页建立确定高度,滚动限制在容器内,不改变其他设置页。 */
:global(.settings-tabs .el-tab-pane:has(> .log-settings-container)) {
height: 100%;
}
@ -105,7 +115,7 @@
.log-content {
flex: 1;
min-height: 120px;
min-height: 260px;
min-width: 0;
display: flex;
flex-direction: column;
@ -361,4 +371,4 @@
.log-categories :deep(.el-radio-button__inner) {
font-size: 12px;
}
}
}

View File

@ -1,173 +1,179 @@
<template>
<section class="log-settings">
<header class="log-heading">
<h2>日志记录</h2>
<span class="log-retention">保留 30 天</span>
</header>
<el-radio-group v-model="category" class="log-categories" aria-label="日志分类">
<el-radio-button v-for="item in categories" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
<div class="log-filters">
<el-date-picker
v-model="dateRange"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm"
aria-label="日志时间范围"
/>
<el-input v-if="!isRuntime && canFilterActor" v-model="actorId" clearable placeholder="操作者编号" aria-label="操作者编号" @keyup.enter="search" />
<div class="log-filter-actions">
<el-button :disabled="loading" @click="recent">最近 24 小时</el-button>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
</div>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<div v-loading="loading" class="log-content" :aria-busy="loading">
<div class="log-table-heading">
<div class="log-table-title">
<strong>{{ currentCategory }}</strong>
<span class="log-file-meta">最新在前 · 每页 10 条</span>
</div>
<div v-if="isRuntime" class="log-summary" aria-label="本页日志统计">
<span>本页 {{ runtimeSummary.total }} 条</span>
<span class="log-stat-info"><i aria-hidden="true" />信息 {{ runtimeSummary.info }}</span>
<span class="log-stat-warning"><i aria-hidden="true" />警告 {{ runtimeSummary.warning }}</span>
<span class="log-stat-danger"><i aria-hidden="true" />错误 {{ runtimeSummary.error }}</span>
<div class="log-settings-container">
<!-- 日志上方:发件人黑白名单管理(支持添加、删除、拉黑/转白,仅当前用户维护自身名单) -->
<SenderFilterManagement />
<section class="log-settings">
<header class="log-heading">
<h2>日志记录</h2>
<span class="log-retention">保留 30 天</span>
</header>
<el-radio-group v-model="category" class="log-categories" aria-label="日志分类">
<el-radio-button v-for="item in categories" :key="item.value" :value="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
<div class="log-filters">
<el-date-picker
v-model="dateRange"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm"
aria-label="日志时间范围"
/>
<el-input v-if="!isRuntime && canFilterActor" v-model="actorId" clearable placeholder="操作者编号" aria-label="操作者编号" @keyup.enter="search" />
<div class="log-filter-actions">
<el-button :disabled="loading" @click="recent">最近 24 小时</el-button>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
</div>
</div>
<el-table
v-if="isRuntime"
:data="runtimeRows"
class="log-table runtime-table"
height="100%"
row-key="sequence"
:row-class-name="runtimeRowClass"
empty-text="暂无运行日志"
@row-click="row => runtimeDetail = row"
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<div v-loading="loading" class="log-content" :aria-busy="loading">
<div class="log-table-heading">
<div class="log-table-title">
<strong>{{ currentCategory }}</strong>
<span class="log-file-meta">最新在前 · 每页 10 条</span>
</div>
<div v-if="isRuntime" class="log-summary" aria-label="本页日志统计">
<span>本页 {{ runtimeSummary.total }} 条</span>
<span class="log-stat-info"><i aria-hidden="true" />信息 {{ runtimeSummary.info }}</span>
<span class="log-stat-warning"><i aria-hidden="true" />警告 {{ runtimeSummary.warning }}</span>
<span class="log-stat-danger"><i aria-hidden="true" />错误 {{ runtimeSummary.error }}</span>
</div>
</div>
<el-table
v-if="isRuntime"
:data="runtimeRows"
class="log-table runtime-table"
height="100%"
row-key="sequence"
:row-class-name="runtimeRowClass"
empty-text="暂无运行日志"
@row-click="row => runtimeDetail = row"
>
<el-table-column label="时间(本地)" width="116">
<template #default="{ row }">
<div class="log-time"><strong>{{ row.time }}</strong><span>{{ row.date }}</span></div>
</template>
</el-table-column>
<el-table-column label="级别" width="76" align="center">
<template #default="{ row }">
<el-tag :type="row.type" size="small" effect="light" class="log-level-tag">{{ row.label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作与结果说明" min-width="170">
<template #default="{ row }">
<strong class="log-action">{{ row.action }}</strong>
<div class="log-message">{{ row.outcome }}</div>
<div v-if="row.object" class="log-source">对象:{{ row.object }}</div>
</template>
</el-table-column>
<el-table-column label="结果" width="92" align="center">
<template #default="{ row }">
<el-tag :type="resultType(row.result)" size="small" class="log-level-tag">{{ resultLabel(row.result) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" width="60" align="center">
<template #default="{ row }">
<el-button link type="primary" :aria-label="`查看第 ${row.sequence} 条日志详情`" @click.stop="runtimeDetail = row">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-table v-else :data="items" class="log-table" height="100%" row-key="id" stripe empty-text="暂无日志记录" @row-click="item => detail = item">
<el-table-column label="时间(本地)" width="116">
<template #default="{ row }">
<div class="log-time"><strong>{{ timeParts(row.createdAt).time }}</strong><span>{{ timeParts(row.createdAt).date }}</span></div>
</template>
</el-table-column>
<el-table-column label="操作与结果说明" min-width="170">
<template #default="{ row }">
<strong class="log-action">{{ actionLabel(row.action) }}</strong>
<div class="log-message">{{ displayOutcome(row) }}</div>
<div class="log-source">{{ displayObject(row) }}</div>
</template>
</el-table-column>
<el-table-column label="操作者" min-width="85" show-overflow-tooltip>
<template #default="{ row }">{{ displayActor(row) }}</template>
</el-table-column>
<el-table-column label="结果" width="82" align="center">
<template #default="{ row }">
<el-tag :type="resultType(row.result)" size="small" class="log-level-tag">{{ resultLabel(row.result) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" width="60" align="center">
<template #default="{ row }"><el-button link type="primary" @click.stop="detail = row">查看</el-button></template>
</el-table-column>
</el-table>
</div>
<footer class="log-pagination">
<span aria-live="polite">{{ pageRange }}</span>
<div class="log-page-actions">
<el-button size="small" :disabled="loading || offset === 0" @click="previous">上一页</el-button>
<el-button size="small" :disabled="loading || !hasMore" @click="next">下一页</el-button>
</div>
</footer>
<el-dialog
:model-value="Boolean(runtimeDetail)"
title="运行日志详情"
width="min(760px, 92vw)"
append-to-body
@close="runtimeDetail = null"
>
<el-table-column label="时间(本地)" width="116">
<template #default="{ row }">
<div class="log-time"><strong>{{ row.time }}</strong><span>{{ row.date }}</span></div>
</template>
</el-table-column>
<el-table-column label="级别" width="76" align="center">
<template #default="{ row }">
<el-tag :type="row.type" size="small" effect="light" class="log-level-tag">{{ row.label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作与结果说明" min-width="170">
<template #default="{ row }">
<strong class="log-action">{{ row.action }}</strong>
<div class="log-message">{{ row.outcome }}</div>
<div v-if="row.object" class="log-source">对象:{{ row.object }}</div>
</template>
</el-table-column>
<el-table-column label="结果" width="92" align="center">
<template #default="{ row }">
<el-tag :type="resultType(row.result)" size="small" class="log-level-tag">{{ resultLabel(row.result) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" width="60" align="center">
<template #default="{ row }">
<el-button link type="primary" :aria-label="`查看第 ${row.sequence} 条日志详情`" @click.stop="runtimeDetail = row">查看</el-button>
</template>
</el-table-column>
</el-table>
<el-table v-else :data="items" class="log-table" height="100%" row-key="id" stripe empty-text="暂无日志记录" @row-click="item => detail = item">
<el-table-column label="时间(本地)" width="116">
<template #default="{ row }">
<div class="log-time"><strong>{{ timeParts(row.createdAt).time }}</strong><span>{{ timeParts(row.createdAt).date }}</span></div>
</template>
</el-table-column>
<el-table-column label="操作与结果说明" min-width="170">
<template #default="{ row }">
<strong class="log-action">{{ actionLabel(row.action) }}</strong>
<div class="log-message">{{ displayOutcome(row) }}</div>
<div class="log-source">{{ displayObject(row) }}</div>
</template>
</el-table-column>
<el-table-column label="操作者" min-width="85" show-overflow-tooltip>
<template #default="{ row }">{{ displayActor(row) }}</template>
</el-table-column>
<el-table-column label="结果" width="82" align="center">
<template #default="{ row }">
<el-tag :type="resultType(row.result)" size="small" class="log-level-tag">{{ resultLabel(row.result) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" width="60" align="center">
<template #default="{ row }"><el-button link type="primary" @click.stop="detail = row">查看</el-button></template>
</el-table-column>
</el-table>
</div>
<footer class="log-pagination">
<span aria-live="polite">{{ pageRange }}</span>
<div class="log-page-actions">
<el-button size="small" :disabled="loading || offset === 0" @click="previous">上一页</el-button>
<el-button size="small" :disabled="loading || !hasMore" @click="next">下一页</el-button>
</div>
</footer>
<el-dialog
:model-value="Boolean(runtimeDetail)"
title="运行日志详情"
width="min(760px, 92vw)"
append-to-body
@close="runtimeDetail = null"
>
<div v-if="runtimeDetail" class="runtime-detail">
<div class="runtime-detail-heading">
<el-tag :type="runtimeDetail.type" effect="light">{{ runtimeDetail.label }}</el-tag>
<span>{{ formatTime(runtimeDetail.createdAt) }}</span>
<span class="runtime-detail-sequence">第 {{ runtimeDetail.sequence }} 条</span>
<div v-if="runtimeDetail" class="runtime-detail">
<div class="runtime-detail-heading">
<el-tag :type="runtimeDetail.type" effect="light">{{ runtimeDetail.label }}</el-tag>
<span>{{ formatTime(runtimeDetail.createdAt) }}</span>
<span class="runtime-detail-sequence">第 {{ runtimeDetail.sequence }} 条</span>
</div>
<dl class="log-detail-grid runtime-detail-meta">
<dt>触发操作</dt><dd>{{ runtimeDetail.action }}</dd>
<dt>操作对象</dt><dd>{{ runtimeDetail.object || '未记录' }}</dd>
<dt>操作结果</dt><dd>{{ resultLabel(runtimeDetail.result) }}</dd>
<dt>结果说明</dt><dd>{{ runtimeDetail.outcome }}</dd>
<dt>代码来源</dt><dd>{{ runtimeDetail.source || '未记录' }}</dd>
<template v-if="runtimeDetail.requestId">
<dt>请求标识</dt><dd>{{ runtimeDetail.requestId }}</dd>
</template>
</dl>
<h3>完整内容</h3>
<pre class="runtime-detail-message">{{ runtimeDetail.message }}</pre>
<details class="runtime-raw">
<summary>原始记录</summary>
<pre>{{ runtimeDetail.raw }}</pre>
</details>
</div>
<dl class="log-detail-grid runtime-detail-meta">
<dt>触发操作</dt><dd>{{ runtimeDetail.action }}</dd>
<dt>操作对象</dt><dd>{{ runtimeDetail.object || '未记录' }}</dd>
<dt>操作结果</dt><dd>{{ resultLabel(runtimeDetail.result) }}</dd>
<dt>结果说明</dt><dd>{{ runtimeDetail.outcome }}</dd>
<dt>代码来源</dt><dd>{{ runtimeDetail.source || '未记录' }}</dd>
<template v-if="runtimeDetail.requestId">
<dt>请求标识</dt><dd>{{ runtimeDetail.requestId }}</dd>
</template>
</dl>
<h3>完整内容</h3>
<pre class="runtime-detail-message">{{ runtimeDetail.message }}</pre>
<details class="runtime-raw">
<summary>原始记录</summary>
<pre>{{ runtimeDetail.raw }}</pre>
</details>
</div>
</el-dialog>
<el-dialog :model-value="Boolean(detail)" title="日志详情" width="min(760px, 92vw)" append-to-body @close="detail = null">
<template v-if="detail">
<p>{{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }}</p>
<dl class="log-detail-grid">
<dt>触发操作</dt><dd>{{ actionLabel(detail.action) }}</dd>
<dt>触发来源</dt><dd>{{ details.trigger || '未记录' }}</dd>
<dt>{{ category === 'mail' ? '处理阶段' : '操作对象' }}</dt><dd>{{ displayObject(detail) }}</dd>
<dt>操作结果</dt><dd>{{ resultLabel(detail.result) }}</dd>
<dt>结果说明</dt><dd>{{ displayOutcome(detail) }}</dd>
</dl>
<dl v-if="category === 'mail'" class="log-detail-grid">
<dt>发件人</dt><dd>{{ details.sender || '—' }}</dd>
<dt>收件人数</dt><dd>{{ details.recipientCount }}</dd>
<dt>邮件大小</dt><dd>{{ details.bytes }} 字节</dd>
<dt>对端接收人数</dt><dd>{{ details.acceptedCount ?? '未记录' }}</dd>
</dl>
<dl v-else class="log-detail-grid">
<dt>权限角色</dt><dd>{{ details.actorRole || '—' }}</dd>
<dt>结果代码</dt><dd>{{ details.errorCode ?? '—' }}</dd>
<dt>HTTP 状态</dt><dd>{{ details.httpStatus || '未记录' }}</dd>
</dl>
</template>
</el-dialog>
</section>
</el-dialog>
<el-dialog :model-value="Boolean(detail)" title="日志详情" width="min(760px, 92vw)" append-to-body @close="detail = null">
<template v-if="detail">
<p>{{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }}</p>
<dl class="log-detail-grid">
<dt>触发操作</dt><dd>{{ actionLabel(detail.action) }}</dd>
<dt>触发来源</dt><dd>{{ details.trigger || '未记录' }}</dd>
<dt>{{ category === 'mail' ? '处理阶段' : '操作对象' }}</dt><dd>{{ displayObject(detail) }}</dd>
<dt>操作结果</dt><dd>{{ resultLabel(detail.result) }}</dd>
<dt>结果说明</dt><dd>{{ displayOutcome(detail) }}</dd>
</dl>
<dl v-if="category === 'mail'" class="log-detail-grid">
<dt>发件人</dt><dd>{{ details.sender || '—' }}</dd>
<dt>收件人数</dt><dd>{{ details.recipientCount }}</dd>
<dt>邮件大小</dt><dd>{{ details.bytes }} 字节</dd>
<dt>对端接收人数</dt><dd>{{ details.acceptedCount ?? '未记录' }}</dd>
</dl>
<dl v-else class="log-detail-grid">
<dt>权限角色</dt><dd>{{ details.actorRole || '—' }}</dd>
<dt>结果代码</dt><dd>{{ details.errorCode ?? '—' }}</dd>
<dt>HTTP 状态</dt><dd>{{ details.httpStatus || '未记录' }}</dd>
</dl>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup>
import { useLogs } from './LogSettings.js'
import SenderFilterManagement from '../SenderFilterManagement/SenderFilterManagement.vue'
const {
category, categories, loading, error, items, offset,

View File

@ -0,0 +1,102 @@
.sender-filter-management {
background: var(--el-bg-color-overlay, #ffffff);
border: 1px solid var(--el-border-color-light, #e4e7ed);
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.sf-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.sf-title-group {
display: flex;
align-items: center;
gap: 12px;
}
.sf-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary, #303133);
}
.sf-badges {
display: flex;
gap: 8px;
}
.sf-badge {
font-size: 12px;
}
.sf-quick-add {
display: flex;
align-items: center;
gap: 8px;
}
.sf-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.sf-tabs {
margin-bottom: 0;
}
.sf-search-input {
width: 260px;
}
.sf-table-wrapper {
margin-bottom: 12px;
}
.sf-table {
width: 100%;
}
.sf-actions {
display: flex;
align-items: center;
gap: 8px;
}
.sf-pagination {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
@media (max-width: 768px) {
.sf-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.sf-quick-add {
width: 100%;
flex-wrap: wrap;
}
.sf-quick-add .el-input {
width: 100% !important;
}
.sf-toolbar {
flex-direction: column;
align-items: flex-start;
}
.sf-search-input {
width: 100%;
}
}

View File

@ -0,0 +1,193 @@
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { http } from '@/utils/axios'
export function useSenderFilters() {
const loading = ref(false)
const filterList = ref([])
const total = ref(0)
const whitelistCount = ref(0)
const blacklistCount = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const searchKeyword = ref('')
const currentTab = ref('all') // 'all' | 'whitelist' | 'blacklist'
const addForm = reactive({
email: '',
filter_type: 'whitelist',
})
const addLoading = ref(false)
const loadFilters = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
keyword: searchKeyword.value.trim(),
}
if (currentTab.value === 'whitelist' || currentTab.value === 'blacklist') {
params.filter_type = currentTab.value
}
const res = await http.get('/api/v1/email/sender-filters', { params })
if (res.errorNo === 0 && res.data) {
filterList.value = Array.isArray(res.data.list) ? res.data.list : []
total.value = res.data.total || 0
whitelistCount.value = res.data.whitelist_count || 0
blacklistCount.value = res.data.blacklist_count || 0
} else {
filterList.value = []
total.value = 0
if (res.errorNo !== 0) {
ElMessage.error(res.errorMsg || '加载名单失败')
}
}
} catch (error) {
filterList.value = []
total.value = 0
ElMessage.error(error?.message || '加载名单失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
loadFilters()
}
const handleTabChange = () => {
currentPage.value = 1
loadFilters()
}
const handlePageChange = (page) => {
currentPage.value = page
loadFilters()
}
const handleAdd = async () => {
const email = addForm.email.trim()
if (!email) {
ElMessage.warning('请输入发件人邮件地址')
return
}
addLoading.value = true
try {
const res = await http.post('/api/v1/email/sender-filters', {
email,
filter_type: addForm.filter_type,
})
if (res.errorNo === 0) {
ElMessage.success(addForm.filter_type === 'whitelist' ? '已成功添加至信任白名单' : '已成功添加至拦截黑名单')
addForm.email = ''
loadFilters()
} else {
ElMessage.error(res.errorMsg || '添加失败')
}
} catch (error) {
ElMessage.error(error?.message || '添加失败')
} finally {
addLoading.value = false
}
}
const handleToggle = async (row) => {
const targetType = row.filter_type === 'whitelist' ? 'blacklist' : 'whitelist'
const actionText = targetType === 'blacklist' ? '拉入黑名单' : '转为白名单'
const tip = targetType === 'blacklist'
? `确定将发件人 "${row.email}" 拉入黑名单吗?后续来自该地址的邮件将受到强拦截与风险警示提示。`
: `确定将发件人 "${row.email}" 转为信任白名单吗?`
try {
await ElMessageBox.confirm(tip, `确认${actionText}`, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: targetType === 'blacklist' ? 'warning' : 'info',
})
const res = await http.post('/api/v1/email/sender-filters/toggle', {
email: row.email,
target_type: targetType,
})
if (res.errorNo === 0) {
ElMessage.success(`已将 ${row.email} ${actionText}`)
loadFilters()
} else {
ElMessage.error(res.errorMsg || `${actionText}失败`)
}
} catch (e) {
if (e !== 'cancel') {
ElMessage.error(e?.message || `${actionText}失败`)
}
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
`确定从名单中移除发件人 "${row.email}" 吗?`,
'确认移除',
{
confirmButtonText: '确定移除',
cancelButtonText: '取消',
type: 'warning',
}
)
const res = await http.post('/api/v1/email/sender-filters/delete', {
email: row.email,
id: row.id,
})
if (res.errorNo === 0) {
ElMessage.success('已移除发件人规则')
loadFilters()
} else {
ElMessage.error(res.errorMsg || '移除失败')
}
} catch (e) {
if (e !== 'cancel') {
ElMessage.error(e?.message || '移除失败')
}
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const d = new Date(dateStr)
if (isNaN(d.getTime())) return dateStr
const pad = (n) => String(n).padStart(2, '0')
const year = d.getFullYear()
const month = pad(d.getMonth() + 1)
const day = pad(d.getDate())
const hours = pad(d.getHours())
const minutes = pad(d.getMinutes())
return `${year}-${month}-${day} ${hours}:${minutes}`
}
onMounted(() => {
loadFilters()
})
return {
loading,
filterList,
total,
whitelistCount,
blacklistCount,
currentPage,
pageSize,
searchKeyword,
currentTab,
addForm,
addLoading,
loadFilters,
handleSearch,
handleTabChange,
handlePageChange,
handleAdd,
handleToggle,
handleDelete,
formatDate,
}
}

View File

@ -0,0 +1,182 @@
<template>
<div class="sender-filter-management" v-loading="loading">
<!-- 面板标题与快捷操作栏 -->
<div class="sf-header">
<div class="sf-title-group">
<h3 class="sf-title">发件人黑白名单管理</h3>
<div class="sf-badges">
<el-tag type="success" effect="plain" class="sf-badge">
信任白名单: {{ whitelistCount }}
</el-tag>
<el-tag type="danger" effect="plain" class="sf-badge">
拦截黑名单: {{ blacklistCount }}
</el-tag>
</div>
</div>
<div class="sf-quick-add">
<el-input
v-model="addForm.email"
placeholder="输入发件人邮箱 (如 friend@example.com)"
clearable
size="default"
style="width: 280px"
@keyup.enter="handleAdd"
/>
<el-select
v-model="addForm.filter_type"
size="default"
style="width: 120px"
>
<el-option label="信任白名单" value="whitelist" />
<el-option label="拦截黑名单" value="blacklist" />
</el-select>
<el-button
type="primary"
size="default"
:loading="addLoading"
@click="handleAdd"
>
添加名单
</el-button>
</div>
</div>
<!-- 筛选与搜索工具条 -->
<div class="sf-toolbar">
<el-radio-group
v-model="currentTab"
size="small"
class="sf-tabs"
@change="handleTabChange"
>
<el-radio-button value="all">全部 ({{ total }})</el-radio-button>
<el-radio-button value="whitelist">仅白名单 ({{ whitelistCount }})</el-radio-button>
<el-radio-button value="blacklist">仅黑名单 ({{ blacklistCount }})</el-radio-button>
</el-radio-group>
<div class="sf-search">
<el-input
v-model="searchKeyword"
placeholder="搜索邮箱地址..."
clearable
size="small"
class="sf-search-input"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #append>
<el-button size="small" @click="handleSearch">搜索</el-button>
</template>
</el-input>
</div>
</div>
<!-- 规则表格 -->
<div class="sf-table-wrapper">
<el-table
:data="filterList"
stripe
size="small"
class="sf-table"
empty-text="暂无匹配的发件人规则"
>
<el-table-column prop="email" label="发件人邮箱" min-width="200" show-overflow-tooltip />
<el-table-column label="名单类型" width="130" align="center">
<template #default="{ row }">
<el-tag
v-if="row.filter_type === 'whitelist'"
type="success"
size="small"
effect="light"
>
信任白名单
</el-tag>
<el-tag
v-else
type="danger"
size="small"
effect="light"
>
拦截黑名单
</el-tag>
</template>
</el-table-column>
<el-table-column label="更新/添加时间" width="160" align="center">
<template #default="{ row }">
<span>{{ formatDate(row.updated_at || row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="管理操作" width="180" align="center">
<template #default="{ row }">
<div class="sf-actions">
<el-button
v-if="row.filter_type === 'whitelist'"
type="warning"
link
size="small"
@click="handleToggle(row)"
>
拉入黑名单
</el-button>
<el-button
v-else
type="success"
link
size="small"
@click="handleToggle(row)"
>
转为白名单
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
移除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div v-if="total > pageSize" class="sf-pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
size="small"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script setup>
import { useSenderFilters } from './SenderFilterManagement.js'
import './SenderFilterManagement.css'
const {
loading,
filterList,
total,
whitelistCount,
blacklistCount,
currentPage,
pageSize,
searchKeyword,
currentTab,
addForm,
addLoading,
loadFilters,
handleSearch,
handleTabChange,
handlePageChange,
handleAdd,
handleToggle,
handleDelete,
formatDate,
} = useSenderFilters()
</script>