feat: 优化已配置客户端列表与发信中继配置

This commit is contained in:
smail 2026-10-19 21:24:37 +08:00
parent 3015081e19
commit bbf26f91c5
7 changed files with 599 additions and 678 deletions

View File

@ -3,138 +3,142 @@
flex-direction: column;
gap: 16px;
grid-column: 1 / -1;
width: 100%;
}
.relay-list-heading,
.relay-item-heading,
.relay-editor-heading,
.relay-item-actions,
.relay-editor-actions {
/* 顶部卡片 */
.relay-header-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.relay-item-actions,
.relay-editor-actions {
justify-content: flex-end;
flex-wrap: nowrap;
min-width: 0;
}
.relay-item-actions :deep(.el-button),
.relay-editor-actions :deep(.el-button) {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
margin: 0;
white-space: nowrap;
}
.relay-list-heading h5,
.relay-editor-heading h5 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 15px;
}
.relay-list-heading p,
.relay-editor-heading span,
.relay-subtitle {
margin: 5px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.relay-list {
display: grid;
gap: 12px;
}
.relay-item {
padding: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
gap: 20px;
padding: 16px 20px;
background: var(--el-fill-color-blank);
}
.relay-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 20px;
margin: 18px 0;
}
.relay-summary div {
min-width: 0;
}
.relay-summary dt {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.relay-summary dd {
margin: 4px 0 0;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.relay-editor {
padding: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-fill-color-lighter);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
}
.relay-fields {
display: grid;
align-items: start;
margin-top: 16px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px 16px;
.relay-title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.relay-fields :deep(.config-field) {
min-width: 0;
.relay-main-title {
display: flex;
align-items: center;
gap: 10px;
}
.relay-main-title h5 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.relay-status-tag {
font-weight: 500;
}
.relay-desc {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.relay-switch-control {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
background: var(--el-fill-color-light);
padding: 8px 14px;
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
}
.control-label {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.relay-alert {
margin: 0;
}
@container config-main (max-width: 760px) {
.relay-fields {
/* 编辑表单平铺卡片 */
.relay-editor-box {
padding: 18px 20px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 16px;
}
.editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 12px;
}
.editor-title-wrap h6 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.editor-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
display: block;
margin-top: 4px;
}
.editor-btns {
display: flex;
gap: 10px;
flex-shrink: 0;
}
/* 紧凑响应式栅格:一行两列/三列 */
.relay-grid-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px 18px;
align-items: start;
}
.relay-grid-form :deep(.config-field) {
min-width: 0;
margin-bottom: 0;
}
@media (max-width: 1100px) {
.relay-grid-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container config-main (max-width: 500px) {
.relay-fields {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 900px) {
.relay-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.relay-list-heading,
.relay-item-heading,
.relay-editor-heading {
align-items: flex-start;
@media (max-width: 650px) {
.relay-header-card {
flex-direction: column;
align-items: flex-start;
}
.relay-summary {
.relay-grid-form {
grid-template-columns: 1fr;
}
}
.editor-header {
flex-direction: column;
align-items: flex-start;
}
}

View File

@ -1,61 +1,67 @@
<template>
<div class="relay-config">
<div class="relay-list-heading">
<div>
<h5>已配置的 SMTP 中继</h5>
<p>中继配置独立于本机邮件服务;关闭开关只停止使用,不会删除已填写的信息。</p>
<!-- 顶部标题与控制开关 -->
<div class="relay-header-card">
<div class="relay-title-group">
<div class="relay-main-title">
<h5>SMTP 发信中继</h5>
<el-tag :type="relay.enabled ? 'success' : 'info'" effect="light" class="relay-status-tag">
{{ relay.enabled ? '中继服务:已启用' : '中继服务:已停用' }}
</el-tag>
</div>
<p class="relay-desc">
配置独立于本机邮件服务;关闭中继开关只停止使用,绝不删除已填写的中继信息。
</p>
</div>
<div class="relay-switch-control">
<span class="control-label">中继控制开关</span>
<el-switch
:model-value="!!relay.enabled"
:disabled="disabled"
active-text="启用"
inactive-text="关闭"
inline-prompt
@change="val => emit('change', ['relay', 'enabled'], val)"
/>
</div>
<el-button v-if="hasConfigured && !editing" type="primary" plain :disabled="disabled" @click="editing = true">
编辑中继
</el-button>
</div>
<!-- 清除标记提醒 -->
<el-alert
v-if="relay.clear"
title="已标记清除,保存后将删除服务器、端口、账号、密码、发件邮箱和发件域名。"
title="中继配置已标记清除,保存生效后将删除服务器、端口、账号、密码及发信配置。"
type="warning"
:closable="false"
show-icon
class="relay-alert"
/>
<div v-if="hasConfigured && !editing" class="relay-list" role="list" aria-label="SMTP 中继列表">
<article class="relay-item" role="listitem">
<div class="relay-item-heading">
<div>
<strong>默认 SMTP 中继</strong>
<span class="relay-subtitle">单个默认发信中继配置</span>
</div>
<el-tag :type="relay.enabled ? 'success' : 'info'" effect="plain">
{{ relay.enabled ? '已启用' : '已关闭' }}
</el-tag>
<!-- 中继编辑表单区域:平铺展示,不再折叠收起 -->
<div class="relay-editor-box">
<div class="editor-header">
<div class="editor-title-wrap">
<h6>编辑 SMTP 中继配置</h6>
<span class="editor-tip">表单字段已按响应式一行两列/三列进行紧凑排列;密码留空保持原值。</span>
</div>
<dl class="relay-summary">
<div><dt>SMTP 服务器</dt><dd>{{ relay.relayHost || '未填写' }}</dd></div>
<div><dt>端口</dt><dd>{{ relay.relayPort || '未填写' }}</dd></div>
<div><dt>中继加密方式</dt><dd>{{ relay.security === 'starttls' ? 'STARTTLS' : relay.security === 'implicit_tls' ? '隐式 TLS' : '未选择' }}</dd></div>
<div><dt>认证账号</dt><dd>{{ relay.username || '未填写' }}</dd></div>
<div><dt>发件邮箱</dt><dd>{{ relay.from || '未填写' }}</dd></div>
<div><dt>密码</dt><dd>{{ passwordConfigured ? '已配置(不显示明文)' : '未配置' }}</dd></div>
<div><dt>发件域名</dt><dd>{{ relay.fromDomain || '未填写' }}</dd></div>
</dl>
<div class="relay-item-actions">
<el-button type="primary" plain :disabled="disabled" @click="editing = true">编辑</el-button>
<el-button type="danger" plain :disabled="disabled" @click="clearRelay">清除中继</el-button>
<div class="editor-btns">
<el-button
v-if="hasConfigured"
type="danger"
plain
size="small"
:disabled="disabled"
@click="clearRelay"
>
清除中继
</el-button>
</div>
</article>
</div>
<el-empty v-else-if="!editing" description="暂无已配置的 SMTP 中继" />
<div v-if="editing" class="relay-editor">
<div class="relay-editor-heading">
<h5>{{ hasConfigured ? '编辑 SMTP 中继' : '添加 SMTP 中继' }}</h5>
<span>密码留空表示保持原值,清除操作需要单独点击“清除中继”。</span>
</div>
<div class="form-grid relay-fields">
<!-- 紧凑表单排版:一行两列/三列 -->
<div class="relay-grid-form">
<ConfigField
v-for="field in fields"
v-for="field in formFields"
:key="fieldKey(field)"
:field="field"
:value="getValue(config, field.path)"
@ -64,16 +70,12 @@
@change="(path, value) => emit('change', path, value)"
/>
</div>
<div class="relay-editor-actions">
<el-button v-if="hasConfigured" :disabled="disabled" @click="editing = false">取消编辑</el-button>
<el-button v-if="hasConfigured" type="danger" plain :disabled="disabled" @click="clearRelay">清除中继</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import ConfigField from '../ConfigField/ConfigField.vue'
import { fieldKey, getValue, relayConfigured } from '../ConfigForm/ConfigForm.js'
@ -85,10 +87,14 @@ const props = defineProps({
disabled: Boolean
})
const emit = defineEmits(['change'])
const relay = computed(() => props.config.relay || {})
const hasConfigured = computed(() => relayConfigured(props.config))
const passwordConfigured = computed(() => !!props.config.relayPasswordStatus?.configured)
const editing = ref(!hasConfigured.value)
// 过滤掉已在顶部独立展示的中继开关,其余表单字段在下方平铺展示
const formFields = computed(() => {
return (props.fields || []).filter(f => fieldKey(f) !== 'relay.enabled')
})
const clearRelay = async () => {
try {
@ -98,11 +104,10 @@ const clearRelay = async () => {
{ type: 'warning', confirmButtonText: '确认清除', cancelButtonText: '取消' }
)
emit('change', ['relay', 'clear'], true)
editing.value = false
} catch (error) {
if (error !== 'cancel' && error !== 'close') throw error
}
}
</script>
<style scoped src="./RelayConfig.css"></style>
<style scoped src="./RelayConfig.css"></style>

View File

@ -5,311 +5,233 @@
gap: 16px;
}
.authorization-section {
display: flex;
flex-direction: column;
gap: 16px;
.settings-card {
padding: 0;
}
/* 通用板块卡片容器 */
.panel-card {
background: var(--pm-bg-secondary);
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-md);
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* 顶部标题栏 */
.card-header-bar {
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: baseline;
gap: 10px;
.settings-header h3 {
font-size: 18px;
font-weight: 600;
color: var(--pm-text-primary);
margin: 0;
min-width: 0;
flex-wrap: wrap;
}
.main-title {
font-size: 16px;
font-weight: 700;
color: var(--pm-text-primary);
.group-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 8px;
flex: 0 1 auto;
min-width: 0;
}
.group-toolbar :deep(.el-button) {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
min-height: 32px;
margin: 0;
white-space: nowrap;
}
.sub-title {
font-size: 13px;
color: var(--pm-text-secondary);
line-height: 1.4;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
/* 核心主要操作按钮:高对比度实心显色,杜绝半透明或虚化 */
.btn-primary-action,
.btn-primary-action.el-button--primary {
background-color: var(--pm-primary-color) !important;
border-color: var(--pm-primary-color) !important;
color: #ffffff !important;
font-weight: 600 !important;
opacity: 1 !important;
box-shadow: 0 2px 6px rgba(0, 113, 227, 0.25);
transition: all 0.2s ease;
}
.btn-primary-action.el-button--primary:hover {
background-color: var(--pm-primary-hover, #0077ed) !important;
border-color: var(--pm-primary-hover, #0077ed) !important;
color: #ffffff !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 113, 227, 0.35);
}
.btn-primary-action.el-button--primary:active {
transform: translateY(0);
}
/* 卡片内容区域 */
.card-content-area {
display: flex;
flex-direction: column;
gap: 12px;
}
.client-op-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.op-left-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.item-label {
font-size: 14px;
font-weight: 600;
color: var(--pm-text-primary);
white-space: nowrap;
}
.client-select-input {
width: 240px;
}
.client-option-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
}
.op-right-group {
display: flex;
align-items: center;
gap: 10px;
}
/* 授权码展示横条 */
.code-banner {
padding: 14px 16px;
/* 面板分组容器 */
.account-group-panel {
margin-top: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 10px;
overflow: hidden;
background: var(--pm-bg-primary);
border: 1px dashed var(--pm-border-color);
border-radius: var(--pm-radius-sm);
}
.account-group-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.banner-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
gap: 12px;
padding: 10px 14px;
background: var(--el-fill-color-light);
}
.banner-label {
.account-group-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--pm-text-primary);
}
.client-count-tag {
font-size: 12px;
color: var(--pm-text-secondary);
}
.proto-desc-hint {
font-size: 12px;
color: var(--pm-text-secondary);
}
/* 表格容器与样式 */
.table-container {
border-top: 1px solid var(--pm-border-color);
overflow-x: hidden;
overflow-y: hidden;
}
.modern-table {
--el-table-header-bg-color: var(--pm-bg-secondary);
--el-table-row-hover-bg-color: color-mix(in srgb, var(--el-color-primary) 6%, transparent);
}
.modern-table :deep(th.el-table__cell) {
background-color: var(--pm-bg-secondary);
color: var(--pm-text-secondary);
font-weight: 600;
}
.modern-table :deep(.el-table__cell) {
padding: 10px 0;
}
.modern-table :deep(.cell) {
padding: 0 8px;
white-space: nowrap;
}
.banner-code-box {
.modern-table :deep(th.el-table__cell .cell) {
font-size: 12px;
}
.client-name-text {
font-weight: 600;
color: var(--pm-text-primary);
}
.status-tag {
border-radius: var(--pm-radius-sm);
min-width: 48px;
justify-content: center;
padding: 0 6px;
font-size: 11px;
font-weight: 500;
}
.time-col {
font-size: 12px;
color: var(--pm-text-secondary);
}
.table-op-cell {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.masked-code {
letter-spacing: 2px;
font-family: monospace;
font-size: 15px;
color: var(--pm-text-secondary);
font-weight: 600;
}
.revealed-code {
padding: 4px 10px;
border-radius: 4px;
.mini-revealed-code {
padding: 2px 6px;
background: color-mix(in srgb, var(--pm-primary-color) 12%, var(--pm-bg-secondary));
color: var(--pm-primary-color);
font-family: monospace;
font-size: 15px;
font-weight: 700;
letter-spacing: 1px;
}
.btn-copy-code {
font-weight: 600;
}
.countdown-tag {
font-weight: 600;
}
.btn-verify-reveal {
font-weight: 600;
}
.text-hint {
font-size: 13px;
color: var(--pm-text-placeholder, #909399);
}
.banner-right {
display: flex;
align-items: center;
gap: 14px;
border-radius: 4px;
font-size: 12px;
color: var(--pm-text-secondary);
margin-left: auto;
letter-spacing: 0.5px;
}
.meta-tag {
white-space: nowrap;
}
/* 空状态占位 */
.empty-client-placeholder {
padding: 24px 16px;
text-align: center;
background: var(--pm-bg-primary);
border: 1px dashed var(--pm-border-color);
.action-btn {
border-radius: var(--pm-radius-sm);
padding: 4px 8px;
margin: 0 !important;
}
/* 授权码倒计时提醒 */
.reveal-alert-bar {
padding: 8px 14px;
background: var(--pm-bg-secondary);
border-top: 1px dashed var(--pm-border-color);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
justify-content: center;
}
.placeholder-text {
font-size: 14px;
color: var(--pm-text-placeholder, #909399);
.reveal-countdown-badge {
font-weight: 600;
font-size: 12px;
}
/* 通用协议指引网格 */
.proto-grid {
/* 分页控件 */
.pagination-wrapper {
margin: 12px 0 16px;
display: flex;
justify-content: center;
}
/* 通用配置协议卡片 */
.proto-guide-section {
margin-top: 16px;
}
.proto-grid-body {
padding: 14px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr));
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
background: var(--pm-bg-primary);
}
.proto-card {
min-width: 0;
padding: 12px 14px;
background: var(--pm-bg-primary);
padding: 12px;
background: var(--pm-bg-secondary);
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
display: flex;
flex-direction: column;
gap: 10px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.proto-card:hover {
border-color: var(--pm-primary-color);
}
.proto-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 6px 8px;
padding-bottom: 9px;
border-bottom: 1px solid var(--pm-border-color);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.proto-name-badge {
position: relative;
min-width: 0;
}
.proto-name-badge > .el-tag {
position: absolute;
top: 0;
right: 0;
padding-inline: 4px;
font-size: 10px;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
}
.proto-title {
display: flex;
flex-direction: column;
min-width: 0;
gap: 2px;
line-height: 1.35;
align-items: baseline;
gap: 6px;
}
.proto-name {
min-width: 0;
font-size: 14px;
font-size: 15px;
font-weight: 700;
color: var(--pm-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.proto-service {
min-width: 0;
font-size: 13px;
font-weight: 600;
color: var(--pm-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--pm-text-secondary);
}
.btn-card-copy {
grid-column: 2;
grid-row: 1;
justify-self: end;
flex-shrink: 0;
padding-inline: 0;
font-size: 12px;
font-weight: 600;
}
@ -317,31 +239,24 @@
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 1px;
font-size: 13px;
}
.field-item {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: baseline;
min-width: 0;
font-size: 13px;
gap: 6px;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.field-k {
color: var(--pm-text-secondary);
white-space: nowrap;
font-size: 12px;
}
.field-v {
min-width: 0;
color: var(--pm-text-primary);
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.font-mono {
@ -353,7 +268,7 @@
font-weight: 700;
}
/* 弹窗通用紧凑样式 */
/* 对话框与通用样式 */
.compact-dialog .dialog-tip {
margin: 0 0 14px;
color: var(--pm-text-secondary);
@ -367,21 +282,21 @@
gap: 10px;
}
@media (max-width: 960px) {
.card-header-bar {
flex-direction: column;
align-items: stretch;
}
.client-op-row {
flex-direction: column;
align-items: stretch;
}
.code-banner {
flex-direction: column;
align-items: stretch;
}
.banner-right {
margin-left: 0;
.btn-primary-action,
.btn-primary-action.el-button--primary {
background-color: var(--pm-primary-color) !important;
border-color: var(--pm-primary-color) !important;
color: #ffffff !important;
font-weight: 600 !important;
}
@container settings-content (max-width: 760px) {
.settings-header {
align-items: center;
}
}
.group-toolbar {
flex: 1 1 100%;
justify-content: flex-start;
}
}

View File

@ -1,7 +1,6 @@
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { ElMessageBox, ElNotification } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue'
import { http } from '@/utils/axios'
export function usePage() {
@ -10,6 +9,16 @@ export function usePage() {
const selectedClientId = ref(null)
const currentDomain = ref('')
// 分页控制
const currentPage = ref(1)
const pageSize = ref(10)
const pagedClients = computed(() => {
const list = clientList.value || []
const start = (currentPage.value - 1) * pageSize.value
return list.slice(start, start + pageSize.value)
})
// 解密后的各客户端授权码明文 { [clientId]: 'xxxx-yyyy-...' }
const revealedCodes = reactive({})
const codesRevealed = ref(false)
@ -31,17 +40,12 @@ export function usePage() {
const verifyActionLabel = computed(() => ({
reveal: '查看明文',
regenerate: '重新生成',
revoke: '撤销授权码',
revoke: '撤销删除',
}[verifyAction.value]))
const verifyDescription = computed(() => verificationClient.value
? `请验证本人登录密码,以${verifyActionLabel.value}:${verificationClient.value.client_name}`
? `请验证本人登录密码,以${verifyActionLabel.value}客户端:${verificationClient.value.client_name}`
: '查看客户端授权码前,请验证本人登录密码。')
const currentClient = computed(() => {
if (!selectedClientId.value || !Array.isArray(clientList.value)) return null
return clientList.value.find(c => c.id === selectedClientId.value) || null
})
const notify = (message, type = 'error') => {
ElNotification({
title: type === 'success' ? '操作成功' : (type === 'info' ? '提示' : '操作失败'),
@ -135,8 +139,8 @@ export function usePage() {
}
}
const handleClientSelect = (val) => {
selectedClientId.value = val
const handlePageChange = (page) => {
currentPage.value = page
}
const openCreateDialog = () => {
@ -266,7 +270,7 @@ export function usePage() {
verifyDialogVisible.value = true
}
const openVerifyDialog = () => openActionVerification('reveal')
const openVerifyDialog = (client) => openActionVerification('reveal', client)
const closeVerifyDialog = () => {
verifyDialogVisible.value = false
@ -344,8 +348,11 @@ export function usePage() {
return {
loading,
clientList,
pagedClients,
currentPage,
pageSize,
handlePageChange,
selectedClientId,
currentClient,
currentDomain,
revealedCodes,
codesRevealed,
@ -359,7 +366,7 @@ export function usePage() {
verifyLoading,
verifyActionLabel,
verifyDescription,
handleClientSelect,
loadClientCredentials,
openCreateDialog,
closeCreateDialog,
submitCreateClient,
@ -372,4 +379,4 @@ export function usePage() {
closeVerifyDialog,
submitVerifyAndReveal,
}
}
}

View File

@ -1,262 +1,249 @@
<template>
<div class="third-party-authorization" v-loading="loading">
<section class="authorization-section">
<!-- 客户端与专属授权码管理卡片 -->
<div class="panel-card client-control-card">
<div class="card-header-bar">
<div class="header-left">
<span class="main-title">客户端专属授权码</span>
<span class="sub-title">为手机、Foxmail、Outlook 等设备创建独立授权码,随时查看或撤销</span>
</div>
<div class="header-actions">
<el-button
type="primary"
size="default"
class="btn-primary-action"
@click="openCreateDialog"
>
+ 新增客户端
</el-button>
</div>
<div class="settings-card">
<div class="settings-header">
<h3>已配置客户端</h3>
<div class="group-toolbar">
<el-button type="primary" :disabled="loading || createLoading" @click="openCreateDialog">
<el-icon><Plus /></el-icon> 新增客户端
</el-button>
<el-button :loading="loading" @click="loadClientCredentials">
<el-icon><Refresh /></el-icon> 刷新列表
</el-button>
</div>
</div>
<div class="card-content-area">
<div class="client-op-row">
<div class="op-left-group">
<span class="item-label">选择客户端:</span>
<el-select
v-model="selectedClientId"
placeholder="请选择客户端设备"
size="default"
class="client-select-input"
@change="handleClientSelect"
>
<el-option
v-for="item in clientList"
:key="item.id"
:label="item.client_name"
:value="item.id"
>
<div class="client-option-item">
<span class="opt-name">{{ item.client_name }}</span>
<el-tag size="small" type="success" effect="light">已就绪</el-tag>
</div>
</el-option>
</el-select>
</div>
<!-- 已配置客户端列表表格 -->
<section class="account-group-panel client-list-panel">
<header class="account-group-header">
<h4>客户端授权管理</h4>
<span class="client-count-tag" v-if="clientList && clientList.length > 0">
共 {{ clientList.length }} 个设备
</span>
</header>
<div class="op-right-group" v-if="currentClient">
<el-button
type="warning"
plain
size="default"
@click="regenerateClientCode(currentClient)"
>
重新生成
</el-button>
<el-button
type="danger"
plain
size="default"
@click="revokeClientCode(currentClient)"
>
撤销删除
</el-button>
</div>
</div>
<!-- 授权码展示横条 -->
<div class="code-banner" v-if="currentClient">
<div class="banner-left">
<span class="banner-label">当前专属授权码:</span>
<div class="banner-code-box">
<template v-if="codesRevealed && revealedCodes[currentClient.id]">
<code class="revealed-code">{{ revealedCodes[currentClient.id] }}</code>
<el-button
type="primary"
size="default"
class="btn-copy-code"
@click="copyCode(revealedCodes[currentClient.id])"
>
一键复制
</el-button>
<el-tag
size="default"
type="warning"
effect="plain"
class="countdown-tag"
>
{{ revealCountdown }}s 后自动隐藏
</el-tag>
</template>
<template v-else-if="codesRevealed && !revealedCodes[currentClient.id]">
<span class="text-hint">未保留加密密文,可点击右上方重新生成</span>
</template>
<template v-else>
<span class="masked-code">••••••••••••••••••••</span>
<div class="table-container">
<el-table
:data="pagedClients"
class="modern-table"
style="width: 100%"
empty-text="暂无已配置的客户端设备,点击上方「新增客户端」创建"
>
<el-table-column label="ID" prop="id" min-width="40" align="center" />
<el-table-column label="客户端名称" prop="client_name" min-width="90" show-overflow-tooltip>
<template #default="scope">
<span class="client-name-text">{{ scope.row.client_name }}</span>
</template>
</el-table-column>
<el-table-column label="状态" min-width="60" align="center">
<template #default>
<el-tag size="small" type="success" effect="light" class="status-tag">已就绪</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="90" align="center">
<template #default="scope">
<span class="time-col">{{ formatTime(scope.row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="最近活跃" min-width="90" align="center">
<template #default="scope">
<span class="time-col">{{ formatTime(scope.row.last_used_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="160" align="right">
<template #default="scope">
<div class="table-op-cell">
<template v-if="codesRevealed && revealedCodes[scope.row.id]">
<code class="mini-revealed-code">{{ revealedCodes[scope.row.id] }}</code>
<el-button
type="primary"
size="small"
class="action-btn"
@click="copyCode(revealedCodes[scope.row.id])"
>
复制
</el-button>
</template>
<el-button
v-else
type="primary"
plain
size="default"
class="btn-verify-reveal"
@click="openVerifyDialog"
size="small"
class="action-btn"
@click="openVerifyDialog(scope.row)"
>
安全验证查看明文
</el-button>
</template>
</div>
</div>
<div class="banner-right">
<span class="meta-tag">创建时间:{{ formatTime(currentClient.created_at) }}</span>
<span class="meta-tag" v-if="currentClient.last_used_at">
最近活跃:{{ formatTime(currentClient.last_used_at) }}
</span>
</div>
</div>
<!-- 空状态引导 -->
<div class="empty-client-placeholder" v-else>
<div class="placeholder-text">暂无可用客户端,请点击右上方「+ 新增客户端」为您的设备配置专属授权码</div>
<el-button
type="primary"
size="default"
class="btn-primary-action"
@click="openCreateDialog"
>
+ 立即新增客户端
</el-button>
</div>
<el-button
type="warning"
plain
size="small"
class="action-btn"
@click="regenerateClientCode(scope.row)"
>
重置
</el-button>
<el-button
type="danger"
plain
size="small"
class="action-btn"
@click="revokeClientCode(scope.row)"
>
撤销删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 倒计时提示栏(当有授权码明文在展示时呈现) -->
<div class="reveal-alert-bar" v-if="codesRevealed">
<el-tag type="warning" effect="plain" class="reveal-countdown-badge">
授权码正在安全展示,{{ revealCountdown }} 秒后自动隐藏
</el-tag>
</div>
<!-- 分页控件 -->
<div class="pagination-wrapper" v-if="clientList && clientList.length > pageSize">
<el-pagination
background
layout="prev, pager, next"
:total="clientList.length"
:page-size="pageSize"
:current-page="currentPage"
@current-change="handlePageChange"
/>
</div>
</section>
<!-- 通用客户端配置参数卡片 -->
<div class="panel-card proto-guide-card">
<div class="card-header-bar">
<div class="header-left">
<span class="main-title">通用客户端配置参数</span>
<span class="sub-title">请使用完整邮箱地址登录,密码统一使用上方专属授权码</span>
</div>
</div>
<section class="proto-guide-section">
<div class="account-group-panel proto-panel">
<header class="account-group-header">
<h4>通用客户端配置参数</h4>
<span class="proto-desc-hint">请使用完整邮箱地址登录,密码统一使用上方专属授权码</span>
</header>
<div class="proto-grid">
<!-- IMAP 收信 -->
<div class="proto-card">
<div class="proto-header">
<div class="proto-name-badge">
<div class="proto-title">
<span class="proto-name">IMAP</span>
<span class="proto-service">收信服务</span>
<div class="proto-grid-body">
<!-- IMAP 收信 -->
<div class="proto-card">
<div class="proto-header">
<div class="proto-name-badge">
<div class="proto-title">
<span class="proto-name">IMAP</span>
<span class="proto-service">收信服务</span>
</div>
<el-tag size="small" type="primary" effect="light">SSL / TLS</el-tag>
</div>
<el-tag size="small" type="primary" effect="light">SSL / TLS</el-tag>
<el-button
type="primary"
link
size="small"
class="btn-card-copy"
:disabled="!currentDomain"
@click="copyText(`imap.${currentDomain}:993`, 'IMAP 参数')"
>
复制
</el-button>
</div>
<el-button
type="primary"
link
size="small"
class="btn-card-copy"
:disabled="!currentDomain"
@click="copyText(`imap.${currentDomain}:993`, 'IMAP 参数')"
>
复制
</el-button>
</div>
<div class="proto-fields">
<div class="field-item">
<span class="field-k">服务器</span>
<span class="field-v font-mono">{{ currentDomain ? `imap.${currentDomain}` : '未配置邮件域名' }}</span>
</div>
<div class="field-item">
<span class="field-k">端口</span>
<span class="field-v font-mono highlight">993</span>
</div>
<div class="field-item">
<span class="field-k">加密</span>
<span class="field-v">SSL / TLS</span>
</div>
</div>
</div>
<!-- POP3 收信 -->
<div class="proto-card">
<div class="proto-header">
<div class="proto-name-badge">
<div class="proto-title">
<span class="proto-name">POP3</span>
<span class="proto-service">收信服务</span>
<div class="proto-fields">
<div class="field-item">
<span class="field-k">服务器</span>
<span class="field-v font-mono">{{ currentDomain ? `imap.${currentDomain}` : '未配置邮件域名' }}</span>
</div>
<el-tag size="small" type="primary" effect="light">SSL / TLS</el-tag>
</div>
<el-button
type="primary"
link
size="small"
class="btn-card-copy"
:disabled="!currentDomain"
@click="copyText(`pop.${currentDomain}:995`, 'POP3 参数')"
>
复制
</el-button>
</div>
<div class="proto-fields">
<div class="field-item">
<span class="field-k">服务器</span>
<span class="field-v font-mono">{{ currentDomain ? `pop.${currentDomain}` : '未配置邮件域名' }}</span>
</div>
<div class="field-item">
<span class="field-k">端口</span>
<span class="field-v font-mono highlight">995</span>
</div>
<div class="field-item">
<span class="field-k">加密</span>
<span class="field-v">SSL / TLS</span>
</div>
</div>
</div>
<!-- SMTP 发信 -->
<div class="proto-card">
<div class="proto-header">
<div class="proto-name-badge">
<div class="proto-title">
<span class="proto-name">SMTP</span>
<span class="proto-service">发信服务</span>
<div class="field-item">
<span class="field-k">端口</span>
<span class="field-v font-mono highlight">993</span>
</div>
<div class="field-item">
<span class="field-k">加密</span>
<span class="field-v">SSL / TLS</span>
</div>
<el-tag size="small" type="primary" effect="light">SSL / STARTTLS</el-tag>
</div>
<el-button
type="primary"
link
size="small"
class="btn-card-copy"
:disabled="!currentDomain"
@click="copyText(`smtp.${currentDomain}:465`, 'SMTP 参数')"
>
复制
</el-button>
</div>
<div class="proto-fields">
<div class="field-item">
<span class="field-k">服务器</span>
<span class="field-v font-mono">{{ currentDomain ? `smtp.${currentDomain}` : '未配置邮件域名' }}</span>
<!-- POP3 收信 -->
<div class="proto-card">
<div class="proto-header">
<div class="proto-name-badge">
<div class="proto-title">
<span class="proto-name">POP3</span>
<span class="proto-service">收信服务</span>
</div>
<el-tag size="small" type="primary" effect="light">SSL / TLS</el-tag>
</div>
<el-button
type="primary"
link
size="small"
class="btn-card-copy"
:disabled="!currentDomain"
@click="copyText(`pop.${currentDomain}:995`, 'POP3 参数')"
>
复制
</el-button>
</div>
<div class="field-item">
<span class="field-k">端口</span>
<span class="field-v font-mono highlight">465 (SSL) / 587</span>
<div class="proto-fields">
<div class="field-item">
<span class="field-k">服务器</span>
<span class="field-v font-mono">{{ currentDomain ? `pop.${currentDomain}` : '未配置邮件域名' }}</span>
</div>
<div class="field-item">
<span class="field-k">端口</span>
<span class="field-v font-mono highlight">995</span>
</div>
<div class="field-item">
<span class="field-k">加密</span>
<span class="field-v">SSL / TLS</span>
</div>
</div>
<div class="field-item">
<span class="field-k">加密</span>
<span class="field-v">SSL / STARTTLS</span>
</div>
<!-- SMTP 发信 -->
<div class="proto-card">
<div class="proto-header">
<div class="proto-name-badge">
<div class="proto-title">
<span class="proto-name">SMTP</span>
<span class="proto-service">发信服务</span>
</div>
<el-tag size="small" type="primary" effect="light">SSL / STARTTLS</el-tag>
</div>
<el-button
type="primary"
link
size="small"
class="btn-card-copy"
:disabled="!currentDomain"
@click="copyText(`smtp.${currentDomain}:465`, 'SMTP 参数')"
>
复制
</el-button>
</div>
<div class="proto-fields">
<div class="field-item">
<span class="field-k">服务器</span>
<span class="field-v font-mono">{{ currentDomain ? `smtp.${currentDomain}` : '未配置邮件域名' }}</span>
</div>
<div class="field-item">
<span class="field-k">端口</span>
<span class="field-v font-mono highlight">465 (SSL) / 587</span>
</div>
<div class="field-item">
<span class="field-k">加密</span>
<span class="field-v">SSL / STARTTLS</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</section>
</div>
<!-- 新增客户端弹窗 -->
<el-dialog
@ -365,12 +352,15 @@
<script setup>
import { usePage } from './ThirdPartyAuthorization.js'
import { Plus, Refresh } from '@element-plus/icons-vue'
const {
loading,
clientList,
selectedClientId,
currentClient,
pagedClients,
currentPage,
pageSize,
handlePageChange,
currentDomain,
revealedCodes,
codesRevealed,
@ -384,7 +374,7 @@ const {
verifyLoading,
verifyActionLabel,
verifyDescription,
handleClientSelect,
loadClientCredentials,
openCreateDialog,
closeCreateDialog,
submitCreateClient,
@ -400,4 +390,4 @@ const {
</script>
<style scoped src="./ThirdPartyAuthorization.css"></style>
<style scoped src="./ThirdPartyAuthorization.css"></style>

View File

@ -22,7 +22,7 @@
.table-container {
border: 1px solid var(--pm-border-color);
border-radius: var(--pm-radius-sm);
overflow-x: auto;
overflow-x: hidden;
overflow-y: hidden;
margin-bottom: 16px;
}

View File

@ -33,10 +33,10 @@
<el-alert v-if="group.error" :title="group.error" type="error" :closable="false" />
<div class="table-container">
<el-table :data="group.rows" class="modern-table" style="width: 100%" empty-text="本组暂无成员">
<el-table-column label="ID" prop="ID" width="44" />
<el-table-column :label="lang.account" prop="Account" width="88" show-overflow-tooltip />
<el-table-column :label="lang.user_name" prop="Name" width="76" show-overflow-tooltip />
<el-table-column label="权限级别" width="100">
<el-table-column label="ID" prop="ID" min-width="40" align="center" />
<el-table-column :label="lang.account" prop="Account" min-width="80" show-overflow-tooltip />
<el-table-column :label="lang.user_name" prop="Name" min-width="70" show-overflow-tooltip />
<el-table-column label="权限级别" min-width="84" align="center">
<template #default="scope">
<el-tag
:type="roleMeta(scope.row.Role).type"
@ -48,7 +48,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="安全码" width="72">
<el-table-column label="安全码" min-width="64" align="center">
<template #default="scope">
<el-tag
:type="recoveryCodeMeta(scope.row.RecoveryCodeConfigured).type"
@ -60,7 +60,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="外部邮箱" width="76">
<el-table-column label="外部邮箱" min-width="70" align="center">
<template #default="scope">
<el-tag
:type="externalEmailMeta(scope.row.ExternalEmailConfigured).type"
@ -72,7 +72,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="账号状态" width="72">
<el-table-column label="账号状态" min-width="68" align="center">
<template #default="scope">
<el-tag
:type="accountStatusMeta(scope.row.Disabled).type"
@ -84,7 +84,7 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="right" :width="canManageMembership ? 186 : 126" fixed="right">
<el-table-column label="操作" align="center" :min-width="canManageMembership ? 130 : 96">
<template #default="scope">
<el-button
size="small"