config-panel-shell.vue 7 KB
<template>
  <div class="config-panel-shell" v-loading="loading">
    <div class="config-panel-shell__header">
      <el-alert :title="alertTitle" :type="alertType" :closable="false" show-icon />
      <div class="config-panel-shell__actions">
        <slot name="header-actions" />
        <el-button
          v-if="showSave"
          type="primary"
          size="small"
          :loading="saving"
          class="config-panel-shell__save"
          @click="$emit('save')"
        >保 存</el-button>
      </div>
    </div>

    <div class="config-panel-shell__body" :class="{ 'is-single': modules.length <= 1 }">
      <aside v-if="modules.length > 1" class="config-panel-shell__nav" :class="navThemeClass">
        <button
          v-for="item in modules"
          :key="item.key"
          type="button"
          class="nav-item"
          :class="{ 'is-active': activeModule === item.key }"
          @click="switchModule(item.key)"
        >
          <i :class="[item.icon, 'module-icon', item.iconClass]"></i>
          <span class="nav-item__text">
            <span class="nav-item__label">{{ item.label }}</span>
            <span v-if="item.desc" class="nav-item__desc">{{ item.desc }}</span>
          </span>
        </button>
      </aside>

      <section class="config-panel-shell__content">
        <header v-if="showContentHeader" class="content-header">
          <h3 class="content-header__title">
            <i :class="[currentModule.icon, 'module-icon', currentModule.iconClass]"></i>
            {{ currentModule.label }}
          </h3>
          <p v-if="currentModule.desc" class="content-header__desc">{{ currentModule.desc }}</p>
        </header>
        <div class="config-panel-shell__slot">
          <slot :name="activeModule" />
        </div>
      </section>
    </div>
    <slot name="append" />
  </div>
</template>

<script>
export default {
  name: 'ConfigPanelShell',
  props: {
    modules: {
      type: Array,
      default: () => []
    },
    loading: {
      type: Boolean,
      default: false
    },
    saving: {
      type: Boolean,
      default: false
    },
    showSave: {
      type: Boolean,
      default: true
    },
    showContentHeader: {
      type: Boolean,
      default: true
    },
    alertTitle: {
      type: String,
      default: '左侧选择模块,右侧仅展示当前配置。'
    },
    alertType: {
      type: String,
      default: 'info'
    },
    storageKey: {
      type: String,
      default: ''
    },
    navTheme: {
      type: String,
      default: 'primary'
    },
    defaultModule: {
      type: String,
      default: ''
    }
  },
  data() {
    return {
      activeModule: this.resolveInitialModule()
    }
  },
  computed: {
    currentModule() {
      return this.modules.find(item => item.key === this.activeModule) || this.modules[0] || { label: '', desc: '', icon: '', iconClass: '' }
    },
    navThemeClass() {
      return `is-theme-${this.navTheme}`
    }
  },
  watch: {
    modules: {
      immediate: true,
      handler(list) {
        if (!list || !list.length) return
        if (!list.some(item => item.key === this.activeModule)) {
          this.activeModule = list[0].key
        }
      }
    }
  },
  methods: {
    resolveInitialModule() {
      const fallback = this.defaultModule || (this.modules[0] && this.modules[0].key) || ''
      if (!this.storageKey) return fallback
      try {
        const stored = localStorage.getItem(this.storageKey)
        if (stored && this.modules.some(item => item.key === stored)) {
          return stored
        }
      } catch (e) {
        // ignore
      }
      return fallback
    },
    switchModule(key) {
      if (this.activeModule === key) return
      this.activeModule = key
      if (this.storageKey) {
        try {
          localStorage.setItem(this.storageKey, key)
        } catch (e) {
          // ignore
        }
      }
      this.$emit('module-change', key)
      this.$nextTick(() => {
        const content = this.$el && this.$el.querySelector('.config-panel-shell__content')
        if (content) content.scrollTop = 0
      })
    }
  }
}
</script>

<style lang="scss" scoped>
.config-panel-shell {
  display: flex;
  flex-direction: column;
  height: calc(100vh - 220px);
  min-height: 480px;
  padding-top: 4px;

  &__header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
    margin-bottom: 16px;
    flex-shrink: 0;
  }

  &__actions {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-shrink: 0;
  }

  &__save {
    width: 100px;
  }

  &__body {
    display: flex;
    flex: 1;
    min-height: 0;
    border: 1px solid #ebeef5;
    border-radius: 8px;
    overflow: hidden;
    background: #fff;

    &.is-single .config-panel-shell__content {
      padding-top: 20px;
    }
  }

  &__nav {
    width: 220px;
    flex-shrink: 0;
    padding: 12px 8px;
    border-right: 1px solid #ebeef5;
    background: #fafafa;
    overflow-y: auto;
  }

  &__content {
    flex: 1;
    min-width: 0;
    padding: 16px 20px 24px;
    overflow-y: auto;
  }

  &__slot {
    min-height: 120px;
  }
}

.nav-item {
  display: flex;
  align-items: flex-start;
  width: 100%;
  margin-bottom: 6px;
  padding: 10px 12px;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  text-align: left;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;

  &:last-child {
    margin-bottom: 0;
  }

  &__text {
    display: flex;
    flex-direction: column;
    min-width: 0;
  }

  &__label {
    color: #303133;
    font-size: 14px;
    font-weight: 500;
    line-height: 20px;
  }

  &__desc {
    margin-top: 2px;
    color: #909399;
    font-size: 12px;
    line-height: 18px;
  }
}

.config-panel-shell__nav.is-theme-primary .nav-item {
  &:hover {
    background: #f0f7ff;
  }

  &.is-active {
    background: #ecf5ff;
    border-color: #c6e2ff;
  }
}

.config-panel-shell__nav.is-theme-warning .nav-item {
  &:hover {
    background: #fdf6ec;
  }

  &.is-active {
    background: #fdf6ec;
    border-color: #f5dab1;
  }
}

.content-header {
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid #ebeef5;

  &__title {
    display: flex;
    align-items: center;
    margin: 0;
    color: #303133;
    font-size: 16px;
    font-weight: 600;
    line-height: 24px;
  }

  &__desc {
    margin: 6px 0 0;
    color: #909399;
    font-size: 13px;
    line-height: 1.6;
  }
}

.module-icon {
  margin-right: 8px;
  font-size: 16px;
  flex-shrink: 0;
}

.ncc-content-dark .config-panel-shell {
  &__body {
    border-color: rgba(255, 255, 255, 0.08);
    background: transparent;
  }

  &__nav {
    border-right-color: rgba(255, 255, 255, 0.08);
    background: rgba(255, 255, 255, 0.03);
  }

  .nav-item__label {
    color: rgba(255, 255, 255, 0.85);
  }

  .nav-item__desc {
    color: rgba(255, 255, 255, 0.55);
  }

  .content-header {
    border-bottom-color: rgba(255, 255, 255, 0.08);

    &__title {
      color: rgba(255, 255, 255, 0.85);
    }

    &__desc {
      color: rgba(255, 255, 255, 0.55);
    }
  }
}
</style>