优化结构
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user