Procházet zdrojové kódy

feat(frontend): 智能工作台模型管理页卡片化改版

- 模型页改为工具栏卡 + 内容卡 + 模型卡片布局
- 新增/编辑弹框改用系统 BasicModal,底部按钮恢复系统统一规范
cc před 1 týdnem
rodič
revize
ed603d0377

+ 5 - 15
ai-electron/frontend/src/ai/components/ModelFormModal.vue

@@ -1,8 +1,9 @@
 <template>
-  <a-modal
+  <BasicModal
     :open="open"
     :title="isEdit ? '编辑模型' : '新增模型'"
     :width="620"
+    wrap-class-name="ai-model-modal"
     :confirm-loading="submitting"
     :mask-closable="false"
     ok-text="保存"
@@ -72,7 +73,7 @@
         </a-collapse-panel>
       </a-collapse>
     </a-form>
-  </a-modal>
+  </BasicModal>
 </template>
 
 <script lang="ts" setup>
@@ -83,22 +84,11 @@
    * 选择厂商后自动带出该厂商默认 Base URL。
    */
   import { computed, reactive, ref, watch } from 'vue';
-  import {
-    Button,
-    Collapse,
-    Form,
-    FormItem,
-    Input,
-    InputPassword,
-    Modal,
-    Select,
-    Switch,
-    Textarea,
-  } from 'ant-design-vue';
+  import { Button, Collapse, Form, FormItem, Input, InputPassword, Select, Switch, Textarea } from 'ant-design-vue';
+  import { BasicModal } from '@/core/components/Modal';
   import type { AiModel, CreateModelParams, ModelProvider, UpdateModelParams } from '../api/types';
   import * as modelApi from '../api/modelApi';
 
-  const AModal = Modal;
   const AForm = Form;
   const AFormItem = FormItem;
   const AInput = Input;

+ 641 - 53
ai-electron/frontend/src/ai/views/aiModel/index.vue

@@ -1,37 +1,66 @@
 <template>
   <div class="ai-page ai-model">
-    <!-- 页签栏即顶部条:页签居左,摘要与操作经 rightExtra 落在同一行右侧 -->
-    <a-tabs v-model:active-key="activeTab" class="ai-model__tabs" size="small" destroy-inactive-tab-pane>
-      <template #rightExtra>
-        <div class="ai-topbar__actions">
-          <a-button :loading="loading" @click="load">
-            <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
-            刷新
-          </a-button>
-          <a-button type="primary" @click="openCreate">
-            <template #icon><Icon icon="mdi:plus" :size="14" /></template>
-            新增{{ activeMeta.label }}
-          </a-button>
-        </div>
-      </template>
-
-      <a-tab-pane v-for="tab in TABS" :key="tab.key">
-        <template #tab>
+    <!-- 顶部工具卡:页签切换 + 摘要 + 当前页签操作(与插件与技能页同一套卡片规范) -->
+    <section class="ai-model__toolbar">
+      <div class="ai-model__card-title">模型管理</div>
+      <div class="ai-model__tabs" role="tablist" aria-label="模型分类">
+        <button
+          v-for="tab in TABS"
+          :key="tab.key"
+          type="button"
+          role="tab"
+          class="ai-model__tab"
+          :class="{ 'is-on': activeTab === tab.key }"
+          :aria-selected="activeTab === tab.key"
+          @click="activeTab = tab.key"
+        >
+          <Icon :icon="tab.icon" :size="14" />
           <span>{{ tab.label }}</span>
           <span class="ai-model__tab-count">{{ listOf(tab.key).length }}</span>
-        </template>
+        </button>
+      </div>
+      <span class="ai-model__summary">{{ summary }}</span>
+      <span class="ai-model__spacer"></span>
 
+      <div class="ai-model__actions">
+        <a-button :loading="loading" @click="load">
+          <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
+          刷新
+        </a-button>
+        <a-button type="primary" @click="openCreate">
+          <template #icon><Icon icon="mdi:plus" :size="14" /></template>
+          新增{{ activeMeta.label }}
+        </a-button>
+      </div>
+    </section>
+
+    <!-- 内容卡:标题/说明 + 模型卡片网格 -->
+    <section class="ai-model__panel">
+      <div class="ai-model__panel-head">
+        <span class="ai-model__card-title">{{ activeMeta.label }}</span>
+        <span class="ai-model__panel-desc">{{ activeMeta.desc }}</span>
+        <span class="ai-model__spacer"></span>
+        <span class="ai-model__panel-count">共 {{ currentModels.length }} 个</span>
+      </div>
+
+      <div class="ai-model__panel-body">
         <a-spin :spinning="loading">
-          <div v-if="listOf(tab.key).length" class="ai-model__grid">
-            <article v-for="record in listOf(tab.key)" :key="record.id" class="ai-model__card">
+          <div v-if="currentModels.length" class="ai-model__grid">
+            <article v-for="record in currentModels" :key="record.id" class="ai-model__card">
               <div class="ai-model__card-head">
-                <div class="ai-model__name">
-                  <span class="ai-model__name-text ai-model__ellipsis" :title="record.name || record.modelId">
-                    {{ record.name || record.modelId }}
-                  </span>
-                  <span v-if="record.defaultModel" class="ai-model__badge">
-                    默认{{ tab.key === 'embedding' ? '向量' : '对话' }}
-                  </span>
+                <span class="ai-model__card-icon" :class="activeTab === 'embedding' ? 'is-embedding' : 'is-llm'">
+                  <Icon :icon="activeMeta.icon" :size="18" />
+                </span>
+                <div class="ai-model__card-heading">
+                  <div class="ai-model__name-row">
+                    <span class="ai-model__name" :title="record.name || record.modelId">
+                      {{ record.name || record.modelId }}
+                    </span>
+                    <span v-if="record.defaultModel" class="ai-model__chip is-primary">
+                      默认{{ activeTab === 'embedding' ? '向量' : '对话' }}
+                    </span>
+                  </div>
+                  <span class="ai-model__card-sub">{{ record.provider || '未指定厂商' }}</span>
                 </div>
                 <span class="ai-model__status" :class="statusMeta(record.status).cls">
                   {{ statusMeta(record.status).label }}
@@ -39,40 +68,59 @@
               </div>
 
               <div class="ai-model__card-meta">
-                <span class="ai-model__chip">{{ record.provider || '—' }}</span>
-                <span class="ai-model__mono">{{ record.modelId || '—' }}</span>
+                <span class="ai-model__meta-label">模型 ID</span>
+                <span class="ai-model__mono ai-model__ellipsis" :title="record.modelId">{{
+                  record.modelId || '—'
+                }}</span>
               </div>
 
               <div class="ai-model__card-meta">
-                <span class="ai-model__mono ai-model__ellipsis" :title="record.baseUrl">
-                  {{ record.baseUrl || '—' }}
-                </span>
+                <span class="ai-model__meta-label">接口地址</span>
+                <span class="ai-model__mono ai-model__ellipsis" :title="record.baseUrl">{{
+                  record.baseUrl || '—'
+                }}</span>
               </div>
 
-              <div class="ai-model__sub">更新:{{ record.updatedAt || record.createdAt || '—' }}</div>
+              <div class="ai-model__card-sub">更新:{{ record.updatedAt || record.createdAt || '—' }}</div>
 
               <div class="ai-model__card-ops">
-                <a-button type="link" size="small" :loading="!!testing[record.id]" @click="onTestConnection(record)">
+                <button
+                  type="button"
+                  class="ai-model__op"
+                  :disabled="!!testing[record.id]"
+                  @click="onTestConnection(record)"
+                >
+                  <Icon v-if="testing[record.id]" icon="mdi:loading" :size="12" spin />
                   测试连接
-                </a-button>
-                <a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
-                <a-button type="link" size="small" :disabled="record.defaultModel" @click="onSetDefault(record, tab)">
+                </button>
+                <button type="button" class="ai-model__op" @click="openEdit(record)">编辑</button>
+                <span class="ai-model__ops-sp"></span>
+                <button
+                  type="button"
+                  class="ai-model__op"
+                  :disabled="record.defaultModel"
+                  @click="onSetDefault(record, activeMeta)"
+                >
                   {{ record.defaultModel ? '已是默认' : '设为默认' }}
-                </a-button>
-                <a-button type="link" size="small" danger @click="onDelete(record)">删除</a-button>
+                </button>
+                <button type="button" class="ai-model__op is-danger" @click="onDelete(record)">删除</button>
               </div>
             </article>
           </div>
-          <div v-else-if="!loading" class="ai-model__empty">
-            {{
-              tab.key === 'embedding'
-                ? '还没有向量模型。向量模型用于表结构语义检索,点击右上角「新增向量模型」添加。'
-                : '还没有对话模型。点击右上角「新增对话模型」添加一个,AI 对话才能开始工作。'
-            }}
-          </div>
+          <EmptyState
+            v-else-if="!loading"
+            :title="activeTab === 'embedding' ? '还没有向量模型' : '还没有对话模型'"
+            :description="
+              activeTab === 'embedding'
+                ? '向量模型用于表结构语义检索,点击右上角「新增向量模型」添加。'
+                : '点击右上角「新增对话模型」添加一个,AI 对话才能开始工作。'
+            "
+            :min-height="300"
+            description-width="min(100%, 520px)"
+          />
         </a-spin>
-      </a-tab-pane>
-    </a-tabs>
+      </div>
+    </section>
 
     <ModelFormModal
       v-model:open="formOpen"
@@ -94,7 +142,8 @@
    * 模型数量为个位数级,故不做服务端分页,列表一次拉全量。
    */
   import { computed, onMounted, ref } from 'vue';
-  import { Button, Modal, Spin, Tabs } from 'ant-design-vue';
+  import { Button, Modal, Spin } from 'ant-design-vue';
+  import { EmptyState } from '@/core/components/EmptyState';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import * as modelApi from '../../api/modelApi';
@@ -103,8 +152,6 @@
 
   const AButton = Button;
   const ASpin = Spin;
-  const ATabs = Tabs;
-  const ATabPane = Tabs.TabPane;
 
   const { showMessage } = useMessage();
 
@@ -114,11 +161,23 @@
   interface ModelTab {
     key: ModelTypeKey;
     label: string;
+    icon: string;
+    desc: string;
   }
 
   const TABS: ModelTab[] = [
-    { key: 'llm', label: '对话模型' },
-    { key: 'embedding', label: '向量模型' },
+    {
+      key: 'llm',
+      label: '对话模型',
+      icon: 'mdi:message-text-outline',
+      desc: '对话与工具调用使用的大语言模型,每个类型各保留一个默认模型。',
+    },
+    {
+      key: 'embedding',
+      label: '向量模型',
+      icon: 'mdi:database-search-outline',
+      desc: '用于表结构语义检索的向量模型,与对话模型分别维护默认项。',
+    },
   ];
 
   const models = ref<AiModel[]>([]);
@@ -138,6 +197,10 @@
     return key === 'embedding' ? embeddingModels.value : llmModels.value;
   }
 
+  const currentModels = computed(() => listOf(activeTab.value));
+
+  const summary = computed(() => `${llmModels.value.length} 个对话模型 · ${embeddingModels.value.length} 个向量模型`);
+
   /**
    * 状态展示:后端 StatusEnum 为 ACTIVE / SUSPENDED / ARCHIVED / DISABLED / DELETED
    * (DELETED 已被列表接口过滤,不会出现)
@@ -445,4 +508,529 @@
       border-top: 1px solid var(--ai-border);
     }
   }
+
+  /* ======================================================================
+   * 卡片化改版:对齐自主模式「持续联系」页与插件与技能页的视觉规范
+   * 页面底 #f4f6fa · 白卡圆角 14 · 描边 #e7ebf3 · 标题蓝条 · 主按钮 #2a50ec
+   * ==================================================================== */
+  .ai-page.ai-model {
+    gap: 8px;
+    width: 100%;
+    height: 100%;
+    min-height: 0;
+    margin: 0;
+    padding: 8px 10px;
+    background: #f4f6fa;
+    border: 0;
+    border-radius: 0;
+    box-sizing: border-box;
+    overflow: hidden;
+  }
+
+  .ai-model {
+    &__toolbar {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 12px;
+      min-height: 52px;
+      padding: 10px 14px;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 14px;
+      box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
+      box-sizing: border-box;
+    }
+
+    &__card-title {
+      position: relative;
+      flex: 0 0 auto;
+      padding-left: 11px;
+      color: #111827;
+      font-size: 15px;
+      font-weight: 600;
+      line-height: 22px;
+      white-space: nowrap;
+
+      &::before {
+        content: '';
+        position: absolute;
+        top: 4px;
+        bottom: 4px;
+        left: 0;
+        width: 3px;
+        background: #2a50ec;
+        border-radius: 2px;
+      }
+    }
+
+    &__tabs {
+      display: inline-flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 2px;
+      padding: 3px;
+      background: #f4f6fa;
+      border: 1px solid #e7ebf3;
+      border-radius: 10px;
+    }
+
+    &__tab {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      height: 28px;
+      padding: 0 10px;
+      color: #5b6577;
+      font-size: 12.5px;
+      font-weight: 500;
+      font-family: inherit;
+      line-height: 1;
+      white-space: nowrap;
+      background: transparent;
+      border: 0;
+      border-radius: 8px;
+      cursor: pointer;
+      transition:
+        color 0.15s ease,
+        background-color 0.15s ease,
+        box-shadow 0.15s ease;
+
+      &:hover {
+        color: #2a50ec;
+        background: rgb(42 80 236 / 6%);
+      }
+
+      &.is-on {
+        color: #2a50ec;
+        font-weight: 600;
+        background: #fff;
+        box-shadow: 0 1px 3px rgb(16 24 40 / 10%);
+      }
+    }
+
+    &__tab-count {
+      display: inline-block;
+      min-width: 18px;
+      margin-left: 0;
+      padding: 0 5px;
+      color: inherit;
+      font-size: 11px;
+      line-height: 16px;
+      text-align: center;
+      background: rgb(42 80 236 / 8%);
+      border: 0;
+      border-radius: 999px;
+    }
+
+    &__summary {
+      max-width: 220px;
+      overflow: hidden;
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__spacer {
+      flex: 1 1 auto;
+    }
+
+    &__actions {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 8px;
+
+      :deep(.ant-btn) {
+        height: 30px;
+        padding: 0 14px;
+        color: #5b6577;
+        font-size: 12px;
+        font-weight: 500;
+        background: #fff;
+        border: 1px solid #e7ebf3;
+        border-radius: 8px;
+        box-shadow: none;
+
+        &:hover,
+        &:focus {
+          color: #2a50ec;
+          background: #f8faff;
+          border-color: #bfcdfb;
+        }
+      }
+
+      :deep(.ant-btn-primary) {
+        color: #fff;
+        background: #2a50ec;
+        border-color: #2a50ec;
+
+        &:hover,
+        &:focus {
+          color: #fff;
+          background: #4b6bf0;
+          border-color: #4b6bf0;
+        }
+
+        &:active {
+          background: #1e3ed0;
+          border-color: #1e3ed0;
+        }
+      }
+
+      :deep(.ant-btn[disabled]) {
+        color: rgb(0 0 0 / 25%);
+        background: #f5f5f5;
+        border-color: #e7ebf3;
+      }
+    }
+
+    &__panel {
+      display: flex;
+      flex: 1 1 auto;
+      flex-direction: column;
+      min-height: 0;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 14px;
+      box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
+      overflow: hidden;
+    }
+
+    &__panel-head {
+      display: flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 10px;
+      min-height: 48px;
+      padding: 8px 14px;
+      border-bottom: 1px solid #eff2f7;
+      box-sizing: border-box;
+    }
+
+    &__panel-desc {
+      min-width: 0;
+      overflow: hidden;
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__panel-count {
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+      font-variant-numeric: tabular-nums;
+    }
+
+    &__panel-body {
+      flex: 1 1 auto;
+      min-height: 0;
+      padding: 14px;
+      overflow-y: auto;
+      overscroll-behavior: contain;
+      box-sizing: border-box;
+    }
+
+    &__grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+      gap: 12px;
+    }
+
+    &__card {
+      gap: 10px;
+      padding: 14px;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 12px;
+      box-sizing: border-box;
+      transition:
+        border-color 0.15s ease,
+        box-shadow 0.15s ease;
+
+      &:hover {
+        border-color: #bfcdfb;
+        box-shadow: 0 4px 14px rgb(16 24 40 / 8%);
+        transform: none;
+      }
+    }
+
+    &__card-head {
+      align-items: flex-start;
+      justify-content: flex-start;
+      gap: 10px;
+    }
+
+    &__card-icon {
+      display: inline-flex;
+      flex: 0 0 auto;
+      align-items: center;
+      justify-content: center;
+      width: 34px;
+      height: 34px;
+      color: #2a50ec;
+      background: rgb(42 80 236 / 8%);
+      border-radius: 9px;
+
+      &.is-embedding {
+        color: #7c3aed;
+        background: rgb(124 58 237 / 8%);
+      }
+    }
+
+    &__card-heading {
+      display: flex;
+      flex: 1 1 auto;
+      flex-direction: column;
+      gap: 4px;
+      min-width: 0;
+    }
+
+    &__name-row {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      min-width: 0;
+    }
+
+    &__name {
+      display: block;
+      min-width: 0;
+      overflow: hidden;
+      color: #111827;
+      font-size: 14px;
+      font-weight: 600;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+
+    &__card-sub {
+      overflow: hidden;
+      color: #98a2b3;
+      font-size: 11.5px;
+      font-family: inherit;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      font-variant-numeric: tabular-nums;
+    }
+
+    &__chip {
+      display: inline-flex;
+      align-items: center;
+      flex: 0 0 auto;
+      max-width: 220px;
+      height: 20px;
+      padding: 0 7px;
+      overflow: hidden;
+      color: #5b6577;
+      font-size: 11px;
+      font-weight: 500;
+      line-height: 18px;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+      background: #f4f6fa;
+      border: 1px solid #e7ebf3;
+      border-radius: 6px;
+
+      &.is-primary {
+        color: #2a50ec;
+        background: rgb(42 80 236 / 7%);
+        border-color: rgb(42 80 236 / 18%);
+      }
+    }
+
+    &__status {
+      display: inline-flex;
+      flex: 0 0 auto;
+      align-items: center;
+      gap: 5px;
+      height: 22px;
+      padding: 0 8px;
+      color: #5b6577;
+      font-size: 11px;
+      font-weight: 500;
+      white-space: nowrap;
+      background: #f4f6fa;
+      border: 1px solid #e7ebf3;
+      border-radius: 6px;
+
+      &::before {
+        content: '';
+        width: 6px;
+        height: 6px;
+        background: currentColor;
+        border-radius: 50%;
+      }
+
+      &.is-on {
+        color: #12805c;
+        background: rgb(18 128 92 / 8%);
+        border-color: rgb(18 128 92 / 20%);
+      }
+
+      &.is-warn {
+        color: #b7791f;
+        background: rgb(183 121 31 / 8%);
+        border-color: rgb(183 121 31 / 20%);
+      }
+
+      &.is-off {
+        color: #5b6577;
+        background: #f4f6fa;
+        border-color: #e7ebf3;
+      }
+    }
+
+    &__card-meta {
+      gap: 8px;
+      min-width: 0;
+    }
+
+    &__meta-label {
+      flex: 0 0 48px;
+      color: #98a2b3;
+      font-size: 11.5px;
+      white-space: nowrap;
+    }
+
+    &__mono {
+      min-width: 0;
+      color: #5b6577;
+      font-size: 11.5px;
+    }
+
+    &__card-ops {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      margin-top: auto;
+      padding-top: 10px;
+      border-top: 1px solid #eff2f7;
+    }
+
+    &__ops-sp {
+      flex: 1 1 auto;
+    }
+
+    &__op {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      height: 26px;
+      padding: 0 10px;
+      color: #5b6577;
+      font-size: 11.5px;
+      font-weight: 500;
+      font-family: inherit;
+      white-space: nowrap;
+      background: #fff;
+      border: 1px solid #e7ebf3;
+      border-radius: 6px;
+      cursor: pointer;
+      transition:
+        color 0.15s ease,
+        background-color 0.15s ease,
+        border-color 0.15s ease;
+
+      &:hover:not(:disabled) {
+        color: #2a50ec;
+        background: #f8faff;
+        border-color: #bfcdfb;
+      }
+
+      &:disabled {
+        cursor: not-allowed;
+        opacity: 0.55;
+      }
+
+      &.is-danger:hover:not(:disabled) {
+        color: #e5484d;
+        background: #fff5f5;
+        border-color: rgb(229 72 77 / 35%);
+      }
+    }
+  }
+
+  @media (max-width: 1440px) {
+    .ai-model__summary {
+      display: none;
+    }
+  }
+
+  @media (max-width: 1180px) {
+    .ai-model__panel-desc {
+      display: none;
+    }
+  }
+
+  @media (max-width: 1120px) {
+    .ai-model__tab .jeesite-icon {
+      display: none !important;
+    }
+  }
+</style>
+
+<style lang="less">
+  /* 新增/编辑模型弹框:与页面卡片统一的圆角、控件尺寸与按钮规范 */
+  /*
+   * 项目全局对 .ant-modal 有过高优先级覆盖(:not(...) 链),这里必须用 !important
+   * 才能把圆角与头部配色统一到页面卡片规范。
+   */
+  .ai-model-modal .ant-modal-content {
+    overflow: hidden;
+    border-radius: 14px !important;
+    padding: 0 !important;
+  }
+
+  .ai-model-modal .ant-modal-header {
+    margin: 0 !important;
+    padding: 14px 20px !important;
+    background: #fff !important;
+    border-bottom: 1px solid #eff2f7 !important;
+    border-radius: 14px 14px 0 0 !important;
+  }
+
+  .ai-model-modal .ant-modal-title {
+    color: #111827 !important;
+    font-size: 15px !important;
+    font-weight: 600 !important;
+  }
+
+  .ai-model-modal .ant-modal-close {
+    top: 14px;
+    right: 16px;
+  }
+
+  .ai-model-modal .ant-modal-body {
+    padding: 16px 20px 8px !important;
+  }
+
+  .ai-model-modal .ant-form-item-label > label {
+    color: #5b6577;
+    font-size: 12px;
+  }
+
+  .ai-model-modal .ant-input,
+  .ai-model-modal .ant-input-affix-wrapper,
+  .ai-model-modal .ant-select-selector {
+    min-height: 32px;
+    border-color: #e7ebf3 !important;
+    border-radius: 8px !important;
+    box-shadow: none;
+  }
+
+  .ai-model-modal .ant-select-selector {
+    padding: 0 10px !important;
+  }
+
+  .ai-model-modal .ant-input:focus,
+  .ai-model-modal .ant-input-affix-wrapper-focused,
+  .ai-model-modal .ant-select-focused .ant-select-selector {
+    border-color: #2a50ec !important;
+    box-shadow: 0 0 0 2px rgb(42 80 236 / 12%) !important;
+  }
 </style>