优化结构

This commit is contained in:
Yvan
2026-08-21 19:11:14 +08:00
parent 690a2d71ca
commit a2ce3ae218
22 changed files with 1292 additions and 734 deletions
+1
View File
@@ -55,6 +55,7 @@
"/src/view/system/security/forceChangePassword.vue": "ForceChangePassword",
"/src/view/system/security/index.vue": "SecurityConfig",
"/src/view/system/state.vue": "State",
"/src/view/systemTools/integration/config.vue": "IntegrationConfig",
"/src/view/systemTools/logViewer/index.vue": "LogViewer",
"/src/view/systemTools/sysError/sysError.vue": "SysError",
"/src/view/systemTools/system/system.vue": "Config",
@@ -0,0 +1,762 @@
<template>
<div class="kra-table-box integration-page">
<header class="page-heading">
<div>
<h2>通信集成</h2>
<p>消息队列与实时连接</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="load">
刷新
</el-button>
</header>
<div v-loading="loading" class="integration-layout">
<aside class="provider-panel" aria-label="通信集成列表">
<div class="panel-heading">
<span>服务</span>
<span>{{ integrations.length }}</span>
</div>
<button
v-for="item in integrations"
:key="integrationKey(item)"
type="button"
class="provider-item"
:class="{ active: integrationKey(item) === selectedKey }"
@click="selectedKey = integrationKey(item)"
>
<span class="provider-icon" aria-hidden="true">
<el-icon><component :is="providerMeta(item).icon" /></el-icon>
</span>
<span class="provider-copy">
<strong>{{ item.name || providerMeta(item).name }}</strong>
<small>{{ providerMeta(item).protocol }}</small>
</span>
<span class="provider-state" :class="{ enabled: item.enabled }">
{{ item.enabled ? '运行中' : '已停用' }}
</span>
</button>
</aside>
<section v-if="selected" class="editor-panel">
<header class="editor-heading">
<div class="editor-title-group">
<div class="editor-title-row">
<h3>{{ selected.name || providerMeta(selected).name }}</h3>
<el-tag v-if="isDirty(selected)" type="warning" effect="plain">
未保存
</el-tag>
</div>
<p>{{ selected.description || providerMeta(selected).description }}</p>
</div>
<div class="enable-control">
<span>{{ selected.enabled ? '已启用' : '已停用' }}</span>
<el-switch
:model-value="selected.enabled"
:loading="isToggling(selected)"
:disabled="isBusy(selected)"
aria-label="启用服务"
@change="(value) => toggleIntegration(selected, value)"
/>
</div>
</header>
<el-alert
v-if="hasMaskedSecret(selected)"
type="info"
:closable="false"
show-icon
class="secret-alert"
title="密码已脱敏;保留 ****** 将继续使用当前密码。"
/>
<el-form
:model="selected.config"
label-position="top"
class="config-form"
@submit.prevent
>
<div class="field-grid">
<el-form-item
v-for="field in selected.fields || []"
:key="field.key"
:required="field.required"
:error="fieldError(selected, field.key)"
>
<template #label>
<span class="field-label">
<span>{{ field.label }}</span>
<small>{{ field.key }}</small>
</span>
</template>
<el-select
v-if="field.type === 'select'"
v-model="selected.config[field.key]"
class="field-control"
:placeholder="field.placeholder || '请选择'"
@update:model-value="clearFieldError(selected, field.key)"
>
<el-option
v-for="option in field.options || []"
:key="String(option.value)"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-switch
v-else-if="field.type === 'switch'"
v-model="selected.config[field.key]"
@update:model-value="clearFieldError(selected, field.key)"
/>
<el-input-number
v-else-if="field.type === 'number'"
v-model="selected.config[field.key]"
class="field-control"
:min="numberConstraint(field.key).min"
:max="numberConstraint(field.key).max"
:step="1"
controls-position="right"
@update:model-value="clearFieldError(selected, field.key)"
/>
<el-input
v-else-if="field.type === 'string-list'"
:model-value="stringListDraft(selected, field.key)"
class="field-control"
type="textarea"
:rows="4"
:placeholder="field.placeholder || '每行一个值'"
spellcheck="false"
@input="(value) => updateStringList(selected, field.key, value)"
/>
<el-input
v-else-if="field.type === 'textarea'"
v-model="selected.config[field.key]"
class="field-control"
type="textarea"
:rows="4"
:placeholder="field.placeholder"
:show-password="field.secret"
spellcheck="false"
@update:model-value="clearFieldError(selected, field.key)"
/>
<el-input
v-else
v-model="selected.config[field.key]"
class="field-control"
:type="field.secret ? 'password' : 'text'"
:show-password="field.secret"
:placeholder="field.placeholder"
spellcheck="false"
@update:model-value="clearFieldError(selected, field.key)"
/>
<p v-if="field.description" class="field-description">
{{ field.description }}
</p>
</el-form-item>
</div>
</el-form>
<footer class="editor-actions">
<span class="save-state">
{{ selected.configured ? '配置已创建' : '尚未保存配置' }}
</span>
<el-button
type="primary"
:icon="Check"
:loading="isSaving(selected)"
:disabled="isBusy(selected) || !isDirty(selected)"
@click="saveSelected"
>
保存配置
</el-button>
</footer>
</section>
<el-empty v-else description="暂无通信集成配置" />
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
ChatLineRound,
Check,
Connection,
Promotion,
Refresh
} from '@element-plus/icons-vue'
import {
getIntegrationConfigs,
saveIntegrationConfig
} from '@/api/integration'
defineOptions({ name: 'IntegrationConfig' })
const TARGETS = {
'mq/emqx': {
name: 'EMQX',
protocol: 'MQTT',
description: 'EMQX MQTT 消息服务',
icon: Connection
},
'mq/rabbitmq': {
name: 'RabbitMQ',
protocol: 'AMQP',
description: 'RabbitMQ 消息队列',
icon: Promotion
},
'websocket/melody': {
name: 'WebSocket',
protocol: 'WS',
description: 'WebSocket 实时连接服务',
icon: ChatLineRound
}
}
const TARGET_ORDER = Object.keys(TARGETS)
const NUMBER_CONSTRAINTS = {
port: { min: 1, max: 65535 },
keep_alive: { min: 1 },
connect_timeout: { min: 1 },
prefetch_count: { min: 0 },
heartbeat: { min: 0 },
max_message_size: { min: 0 },
message_buffer_size: { min: 0 }
}
const DURATION_FIELDS = new Set(['write_wait', 'pong_wait', 'ping_period'])
const DURATION_PATTERN = /^(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+$/i
const integrations = ref([])
const selectedKey = ref(TARGET_ORDER[0])
const loading = ref(false)
const pending = reactive({})
const errors = reactive({})
const listDrafts = reactive({})
const selected = computed(
() =>
integrations.value.find(
(item) => integrationKey(item) === selectedKey.value
) || integrations.value[0]
)
const integrationKey = (item) => `${item.kind}/${item.provider}`
const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]]
const operationKey = (item) => integrationKey(item)
const errorKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
const normalizeIntegration = (item) => {
const normalized = {
...item,
enabled: Boolean(item.enabled),
configured: Boolean(item.configured),
config: cloneConfig(item.config),
fields: Array.isArray(item.fields) ? item.fields : []
}
normalized._savedEnabled = normalized.enabled
normalized._savedConfig = cloneConfig(normalized.config)
for (const field of normalized.fields) {
if (field.type === 'string-list') {
const value = normalized.config[field.key]
listDrafts[listKey(normalized, field.key)] = Array.isArray(value)
? value.join('\n')
: ''
normalized.config[field.key] = Array.isArray(value) ? value : []
}
}
return normalized
}
const load = async () => {
if (loading.value) return
loading.value = true
try {
const [mq, websocket] = await Promise.all([
getIntegrationConfigs('mq'),
getIntegrationConfigs('websocket')
])
if (mq.code !== 0 || websocket.code !== 0) return
Object.keys(listDrafts).forEach((key) => delete listDrafts[key])
Object.keys(errors).forEach((key) => delete errors[key])
const loaded = [...(mq.data || []), ...(websocket.data || [])]
.filter((item) => TARGETS[integrationKey(item)])
.map(normalizeIntegration)
.sort(
(left, right) =>
TARGET_ORDER.indexOf(integrationKey(left)) -
TARGET_ORDER.indexOf(integrationKey(right))
)
integrations.value = loaded
if (!loaded.some((item) => integrationKey(item) === selectedKey.value)) {
selectedKey.value = integrationKey(loaded[0] || { kind: '', provider: '' })
}
} catch {
// The request layer already presents transport errors.
} finally {
loading.value = false
}
}
const isBusy = (item) => Boolean(pending[operationKey(item)])
const isSaving = (item) => pending[operationKey(item)] === 'save'
const isToggling = (item) => pending[operationKey(item)] === 'toggle'
const numberConstraint = (fieldKey) =>
NUMBER_CONSTRAINTS[fieldKey] || { min: undefined, max: undefined }
const fieldError = (item, fieldKey) => errors[errorKey(item, fieldKey)] || ''
const clearFieldError = (item, fieldKey) => {
delete errors[errorKey(item, fieldKey)]
}
const stringListDraft = (item, fieldKey) => listDrafts[listKey(item, fieldKey)] || ''
const updateStringList = (item, fieldKey, value) => {
listDrafts[listKey(item, fieldKey)] = value
item.config[fieldKey] = String(value)
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean)
clearFieldError(item, fieldKey)
}
const isMissing = (value) => {
if (Array.isArray(value)) return value.length === 0
if (typeof value === 'string') return value.trim() === ''
return value === null || typeof value === 'undefined'
}
const validate = (item, enabled = item.enabled) => {
for (const field of item.fields || []) clearFieldError(item, field.key)
let firstInvalid = ''
for (const field of item.fields || []) {
const value = item.config[field.key]
let message = ''
if (enabled && field.required && isMissing(value)) {
message = `请填写${field.label}`
} else if (field.type === 'number' && !isMissing(value)) {
const number = Number(value)
const constraint = numberConstraint(field.key)
if (!Number.isFinite(number)) {
message = `${field.label}必须是数字`
} else if (constraint.min !== undefined && number < constraint.min) {
message = `${field.label}不能小于 ${constraint.min}`
} else if (constraint.max !== undefined && number > constraint.max) {
message = `${field.label}不能大于 ${constraint.max}`
}
} else if (
DURATION_FIELDS.has(field.key) &&
!isMissing(value) &&
!DURATION_PATTERN.test(String(value).trim())
) {
message = `${field.label}格式无效,例如 10s 或 1m30s`
} else if (field.key === 'path' && value && !String(value).startsWith('/')) {
message = '访问路径必须以 / 开头'
}
if (message) {
errors[errorKey(item, field.key)] = message
firstInvalid ||= field.label
}
}
if (firstInvalid) {
ElMessage.warning(`请检查${firstInvalid}等配置项`)
return false
}
return true
}
const savedSnapshot = (item) =>
JSON.stringify({ enabled: item._savedEnabled, config: item._savedConfig })
const currentSnapshot = (item) =>
JSON.stringify({ enabled: item.enabled, config: item.config })
const isDirty = (item) => savedSnapshot(item) !== currentSnapshot(item)
const hasMaskedSecret = (item) =>
(item.fields || []).some(
(field) => field.secret && item.config[field.key] === '******'
)
const markSaved = (item) => {
item.configured = true
item._savedEnabled = item.enabled
item._savedConfig = cloneConfig(item.config)
}
const persist = async (item, operation) => {
const key = operationKey(item)
if (pending[key]) return false
pending[key] = operation
try {
const res = await saveIntegrationConfig(item.kind, item.provider, {
enabled: item.enabled,
config: item.config
})
if (res.code !== 0) {
ElMessage.error(res.msg || '保存失败')
return false
}
markSaved(item)
return true
} catch {
return false
} finally {
delete pending[key]
}
}
const saveSelected = async () => {
const item = selected.value
if (!item || isBusy(item) || !validate(item)) return
if (await persist(item, 'save')) {
ElMessage.success(`${item.name || providerMeta(item).name} 配置已保存`)
}
}
const toggleIntegration = async (item, enabled) => {
if (isBusy(item)) return
const previous = item.enabled
item.enabled = Boolean(enabled)
if (item.enabled && !validate(item, true)) {
item.enabled = previous
return
}
if (await persist(item, 'toggle')) {
ElMessage.success(
`${item.name || providerMeta(item).name} 已${item.enabled ? '启用' : '停用'}`
)
return
}
item.enabled = previous
ElMessage.warning('状态未改变')
}
onMounted(load)
</script>
<style scoped>
.integration-page {
min-height: 640px;
}
.page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.page-heading h2 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 20px;
font-weight: 600;
letter-spacing: 0;
}
.page-heading p {
margin: 5px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.integration-layout {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 560px;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
}
.provider-panel {
padding: 12px 9px;
border-right: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-blank);
}
.panel-heading {
display: flex;
justify-content: space-between;
padding: 4px 10px 11px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.provider-item {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
width: 100%;
min-height: 58px;
padding: 8px 9px;
border: 0;
border-left: 3px solid transparent;
border-radius: 4px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.provider-item:hover {
background: var(--el-fill-color-light);
}
.provider-item.active {
border-left-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.provider-icon {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--el-fill-color);
color: var(--el-text-color-regular);
font-size: 17px;
}
.provider-item.active .provider-icon {
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
}
.provider-copy {
display: grid;
min-width: 0;
gap: 3px;
}
.provider-copy strong {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-copy small {
color: var(--el-text-color-secondary);
font-size: 11px;
}
.provider-state {
color: var(--el-text-color-placeholder);
font-size: 11px;
white-space: nowrap;
}
.provider-state.enabled {
color: var(--el-color-success);
}
.editor-panel {
display: flex;
min-width: 0;
flex-direction: column;
padding: 22px 28px 20px;
}
.editor-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding-bottom: 18px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.editor-title-group {
min-width: 0;
}
.editor-title-row {
display: flex;
align-items: center;
gap: 9px;
}
.editor-title-row h3 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 18px;
font-weight: 600;
letter-spacing: 0;
}
.editor-title-group p {
margin: 6px 0 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.enable-control {
display: flex;
align-items: center;
gap: 10px;
min-height: 32px;
color: var(--el-text-color-regular);
font-size: 13px;
white-space: nowrap;
}
.secret-alert {
margin-top: 18px;
}
.config-form {
flex: 1;
padding-top: 20px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 22px;
}
.field-label {
display: inline-flex;
align-items: baseline;
gap: 7px;
min-width: 0;
}
.field-label small {
overflow: hidden;
color: var(--el-text-color-placeholder);
font-size: 11px;
font-weight: 400;
text-overflow: ellipsis;
}
.field-control {
width: 100%;
}
.field-description {
width: 100%;
margin: 5px 0 0;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 14px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
.save-state {
margin-right: auto;
color: var(--el-text-color-secondary);
font-size: 12px;
}
@media (max-width: 900px) {
.integration-layout {
grid-template-columns: 1fr;
}
.provider-panel {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
border-right: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.panel-heading {
display: none;
}
.provider-item {
grid-template-columns: 30px minmax(0, 1fr);
min-height: 54px;
}
.provider-icon {
width: 28px;
height: 28px;
}
.provider-state {
display: none;
}
.editor-panel {
padding: 20px;
}
.field-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.integration-page {
min-height: 0;
}
.page-heading {
align-items: flex-start;
}
.provider-panel {
grid-template-columns: 1fr;
}
.provider-item {
grid-template-columns: 30px minmax(0, 1fr) auto;
}
.provider-state {
display: inline;
}
.editor-panel {
padding: 18px 14px;
}
.editor-heading {
align-items: stretch;
flex-direction: column;
gap: 14px;
}
.enable-control {
justify-content: space-between;
}
.editor-actions {
align-items: stretch;
flex-direction: column;
}
.save-state {
margin-right: 0;
}
}
</style>