ConfigCenterPanel.vue 5.15 KB
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
  groups: Array<{
    title: string
    fields: Array<{
      key: string
      label: string
      type: string
      value: string
    }>
  }>
  loading: boolean
  saving: boolean
}>()

const emit = defineEmits<{
  updateField: [key: string, value: string]
  refresh: []
  save: []
}>()

const totalFields = computed(() => props.groups.reduce((sum, group) => sum + group.fields.length, 0))
const passwordFields = computed(() => props.groups.reduce((sum, group) => (
  sum + group.fields.filter((field) => field.type === 'password').length
), 0))
const filledFields = computed(() => props.groups.reduce((sum, group) => (
  sum + group.fields.filter((field) => field.value.trim().length > 0).length
), 0))
</script>

<template>
  <section class="config-panel">
    <div v-if="loading" class="archive-empty">
      <p>配置载入中…</p>
    </div>

    <div v-else class="config-layout">
      <div class="config-groups">
        <article
          v-for="group in groups"
          :key="group.title"
          class="archive-panel config-group"
        >
          <div class="config-group__head">
            <div>
              <p class="archive-kicker">Configuration Group</p>
              <h3 class="archive-card-title">{{ group.title }}</h3>
            </div>
            <span class="archive-chip">
              <span class="archive-chip__dot" />
              {{ group.fields.length }} Fields
            </span>
          </div>

          <div class="config-grid">
            <label
              v-for="field in group.fields"
              :key="field.key"
              class="config-field"
            >
              <span class="archive-kicker">{{ field.label }}</span>
              <input
                :type="field.type === 'password' ? 'password' : 'text'"
                class="archive-field"
                :value="field.value"
                @input="$emit('updateField', field.key, ($event.target as HTMLInputElement).value)"
              />
            </label>
          </div>
        </article>
      </div>

      <aside class="config-side">
        <article class="archive-panel">
          <p class="archive-kicker">Environment Snapshot</p>
          <div class="side-stat-list">
            <div class="side-stat">
              <span>Groups</span>
              <strong>{{ groups.length }}</strong>
            </div>
            <div class="side-stat">
              <span>Total Fields</span>
              <strong>{{ totalFields }}</strong>
            </div>
            <div class="side-stat">
              <span>Secrets</span>
              <strong>{{ passwordFields }}</strong>
            </div>
            <div class="side-stat">
              <span>Filled</span>
              <strong>{{ filledFields }}</strong>
            </div>
          </div>
        </article>

        <article class="archive-panel">
          <p class="archive-kicker">Maintenance Notes</p>
          <ul class="note-list">
            <li>敏感字段仍使用密码框展示,保存后建议同步重启相关服务。</li>
            <li>数据库与模型链路建议优先补齐主机、密钥与模型名三类字段。</li>
            <li>搜索与网络组更适合按环境分批维护,避免一次性替换全部参数。</li>
          </ul>
          <div class="config-side__actions">
            <button class="archive-button--ghost" type="button" @click="$emit('refresh')">刷新配置</button>
            <button class="archive-button" type="button" :disabled="saving" @click="$emit('save')">
              {{ saving ? '保存中' : '保存全部' }}
            </button>
          </div>
        </article>
      </aside>
    </div>
  </section>
</template>

<style scoped>
.config-panel,
.config-layout,
.config-groups,
.config-grid,
.config-side,
.side-stat-list {
  display: grid;
  gap: 18px;
}

.config-layout {
  grid-template-columns: minmax(0, 1fr) 320px;
  align-items: start;
}

.config-group__head,
.config-side__actions {
  display: flex;
  justify-content: space-between;
  gap: 14px;
  align-items: center;
}

.config-grid {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.config-field {
  display: grid;
  gap: 10px;
}

.side-stat-list {
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin-top: 12px;
}

.side-stat {
  display: grid;
  gap: 6px;
  padding: 16px;
  border: 1px solid rgba(24, 35, 31, 0.08);
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.66);
}

.side-stat span {
  color: var(--text-soft);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.side-stat strong {
  color: var(--primary);
  font-family: var(--font-display);
  font-size: 30px;
}

.note-list {
  display: grid;
  gap: 10px;
  margin: 12px 0 0;
  padding-left: 18px;
  color: var(--text-muted);
  line-height: 1.8;
}

.config-side__actions {
  margin-top: 18px;
}

@media (max-width: 1280px) {
  .config-layout {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 820px) {
  .config-grid,
  .side-stat-list {
    grid-template-columns: 1fr;
  }

  .config-side__actions {
    flex-direction: column;
    align-items: stretch;
  }
}
</style>