index.vue 27 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036
  1. <template>
  2. <div class="ai-page ai-model">
  3. <!-- 顶部工具卡:页签切换 + 摘要 + 当前页签操作(与插件与技能页同一套卡片规范) -->
  4. <section class="ai-model__toolbar">
  5. <div class="ai-model__card-title">模型管理</div>
  6. <div class="ai-model__tabs" role="tablist" aria-label="模型分类">
  7. <button
  8. v-for="tab in TABS"
  9. :key="tab.key"
  10. type="button"
  11. role="tab"
  12. class="ai-model__tab"
  13. :class="{ 'is-on': activeTab === tab.key }"
  14. :aria-selected="activeTab === tab.key"
  15. @click="activeTab = tab.key"
  16. >
  17. <Icon :icon="tab.icon" :size="14" />
  18. <span>{{ tab.label }}</span>
  19. <span class="ai-model__tab-count">{{ listOf(tab.key).length }}</span>
  20. </button>
  21. </div>
  22. <span class="ai-model__summary">{{ summary }}</span>
  23. <span class="ai-model__spacer"></span>
  24. <div class="ai-model__actions">
  25. <a-button :loading="loading" @click="load">
  26. <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
  27. 刷新
  28. </a-button>
  29. <a-button type="primary" @click="openCreate">
  30. <template #icon><Icon icon="mdi:plus" :size="14" /></template>
  31. 新增{{ activeMeta.label }}
  32. </a-button>
  33. </div>
  34. </section>
  35. <!-- 内容卡:标题/说明 + 模型卡片网格 -->
  36. <section class="ai-model__panel">
  37. <div class="ai-model__panel-head">
  38. <span class="ai-model__card-title">{{ activeMeta.label }}</span>
  39. <span class="ai-model__panel-desc">{{ activeMeta.desc }}</span>
  40. <span class="ai-model__spacer"></span>
  41. <span class="ai-model__panel-count">共 {{ currentModels.length }} 个</span>
  42. </div>
  43. <div class="ai-model__panel-body">
  44. <a-spin :spinning="loading">
  45. <div v-if="currentModels.length" class="ai-model__grid">
  46. <article v-for="record in currentModels" :key="record.id" class="ai-model__card">
  47. <div class="ai-model__card-head">
  48. <span class="ai-model__card-icon" :class="activeTab === 'embedding' ? 'is-embedding' : 'is-llm'">
  49. <Icon :icon="activeMeta.icon" :size="18" />
  50. </span>
  51. <div class="ai-model__card-heading">
  52. <div class="ai-model__name-row">
  53. <span class="ai-model__name" :title="record.name || record.modelId">
  54. {{ record.name || record.modelId }}
  55. </span>
  56. <span v-if="record.defaultModel" class="ai-model__chip is-primary">
  57. 默认{{ activeTab === 'embedding' ? '向量' : '对话' }}
  58. </span>
  59. </div>
  60. <span class="ai-model__card-sub">{{ record.provider || '未指定厂商' }}</span>
  61. </div>
  62. <span class="ai-model__status" :class="statusMeta(record.status).cls">
  63. {{ statusMeta(record.status).label }}
  64. </span>
  65. </div>
  66. <div class="ai-model__card-meta">
  67. <span class="ai-model__meta-label">模型 ID</span>
  68. <span class="ai-model__mono ai-model__ellipsis" :title="record.modelId">{{
  69. record.modelId || '—'
  70. }}</span>
  71. </div>
  72. <div class="ai-model__card-meta">
  73. <span class="ai-model__meta-label">接口地址</span>
  74. <span class="ai-model__mono ai-model__ellipsis" :title="record.baseUrl">{{
  75. record.baseUrl || '—'
  76. }}</span>
  77. </div>
  78. <div class="ai-model__card-sub">更新:{{ record.updatedAt || record.createdAt || '—' }}</div>
  79. <div class="ai-model__card-ops">
  80. <button
  81. type="button"
  82. class="ai-model__op"
  83. :disabled="!!testing[record.id]"
  84. @click="onTestConnection(record)"
  85. >
  86. <Icon v-if="testing[record.id]" icon="mdi:loading" :size="12" spin />
  87. 测试连接
  88. </button>
  89. <button type="button" class="ai-model__op" @click="openEdit(record)">编辑</button>
  90. <span class="ai-model__ops-sp"></span>
  91. <button
  92. type="button"
  93. class="ai-model__op"
  94. :disabled="record.defaultModel"
  95. @click="onSetDefault(record, activeMeta)"
  96. >
  97. {{ record.defaultModel ? '已是默认' : '设为默认' }}
  98. </button>
  99. <button type="button" class="ai-model__op is-danger" @click="onDelete(record)">删除</button>
  100. </div>
  101. </article>
  102. </div>
  103. <EmptyState
  104. v-else-if="!loading"
  105. :title="activeTab === 'embedding' ? '还没有向量模型' : '还没有对话模型'"
  106. :description="
  107. activeTab === 'embedding'
  108. ? '向量模型用于表结构语义检索,点击右上角「新增向量模型」添加。'
  109. : '点击右上角「新增对话模型」添加一个,AI 对话才能开始工作。'
  110. "
  111. :min-height="300"
  112. description-width="min(100%, 520px)"
  113. />
  114. </a-spin>
  115. </div>
  116. </section>
  117. <ModelFormModal
  118. v-model:open="formOpen"
  119. :record="editing"
  120. :providers="providers"
  121. :default-type="activeTab"
  122. @saved="onSaved"
  123. />
  124. </div>
  125. </template>
  126. <script lang="ts" setup name="AiModelIndex">
  127. /**
  128. * AI 模型管理页
  129. *
  130. * 对接 AgentModelController(/models/**,Result 包装)。
  131. * 模型按用途类型分两个 Tab:对话模型(llm)/ 向量模型(embedding),
  132. * 两类各自维护一个默认模型(后端 AgentModelService#setDefaultModel 按 type 隔离)。
  133. * 模型数量为个位数级,故不做服务端分页,列表一次拉全量。
  134. */
  135. import { computed, onMounted, ref } from 'vue';
  136. import { Button, Modal, Spin } from 'ant-design-vue';
  137. import { EmptyState } from '@/core/components/EmptyState';
  138. import Icon from '@/core/components/Icon/src/Icon.vue';
  139. import { useMessage } from '@/core/hooks/web/useMessage';
  140. import * as modelApi from '../../api/modelApi';
  141. import type { AiModel, ModelProvider } from '../../api/types';
  142. import ModelFormModal from '../../components/ModelFormModal.vue';
  143. const AButton = Button;
  144. const ASpin = Spin;
  145. const { showMessage } = useMessage();
  146. /** 用途类型:与后端 ModelTypeEnum 一致 */
  147. type ModelTypeKey = 'llm' | 'embedding';
  148. interface ModelTab {
  149. key: ModelTypeKey;
  150. label: string;
  151. icon: string;
  152. desc: string;
  153. }
  154. const TABS: ModelTab[] = [
  155. {
  156. key: 'llm',
  157. label: '对话模型',
  158. icon: 'mdi:message-text-outline',
  159. desc: '对话与工具调用使用的大语言模型,每个类型各保留一个默认模型。',
  160. },
  161. {
  162. key: 'embedding',
  163. label: '向量模型',
  164. icon: 'mdi:database-search-outline',
  165. desc: '用于表结构语义检索的向量模型,与对话模型分别维护默认项。',
  166. },
  167. ];
  168. const models = ref<AiModel[]>([]);
  169. const providers = ref<ModelProvider[]>([]);
  170. const loading = ref(false);
  171. const formOpen = ref(false);
  172. const editing = ref<AiModel | null>(null);
  173. const activeTab = ref<ModelTypeKey>('llm');
  174. const activeMeta = computed(() => TABS.find((t) => t.key === activeTab.value) || TABS[0]);
  175. /** 对话模型:type 为空的历史数据同样视为对话模型(与后端 ModelTypeEnum.fromCode 一致) */
  176. const llmModels = computed(() => models.value.filter((m) => !m.type || m.type === 'llm'));
  177. const embeddingModels = computed(() => models.value.filter((m) => m.type === 'embedding'));
  178. function listOf(key: ModelTypeKey) {
  179. return key === 'embedding' ? embeddingModels.value : llmModels.value;
  180. }
  181. const currentModels = computed(() => listOf(activeTab.value));
  182. const summary = computed(() => `${llmModels.value.length} 个对话模型 · ${embeddingModels.value.length} 个向量模型`);
  183. /**
  184. * 状态展示:后端 StatusEnum 为 ACTIVE / SUSPENDED / ARCHIVED / DISABLED / DELETED
  185. * (DELETED 已被列表接口过滤,不会出现)
  186. */
  187. const STATUS_META: Record<string, { label: string; cls: string }> = {
  188. ACTIVE: { label: '活跃', cls: 'is-on' },
  189. SUSPENDED: { label: '暂停', cls: 'is-warn' },
  190. ARCHIVED: { label: '归档', cls: 'is-off' },
  191. DISABLED: { label: '禁用', cls: 'is-off' },
  192. DELETED: { label: '已删除', cls: 'is-off' },
  193. };
  194. function statusMeta(status?: string) {
  195. if (!status) return { label: '未知', cls: 'is-off' };
  196. return STATUS_META[status] || { label: status, cls: 'is-off' };
  197. }
  198. onMounted(load);
  199. async function load() {
  200. loading.value = true;
  201. try {
  202. const [list, providerList] = await Promise.all([
  203. modelApi.listModels(),
  204. modelApi.listProviders().catch(() => [] as ModelProvider[]),
  205. ]);
  206. models.value = list || [];
  207. providers.value = providerList || [];
  208. } catch (e: any) {
  209. showMessage(e?.message || '加载模型列表失败', 'error');
  210. } finally {
  211. loading.value = false;
  212. }
  213. }
  214. /** 新增时默认落在当前 Tab 的类型下,避免用户新增完发现"不在当前列表里" */
  215. function openCreate() {
  216. editing.value = null;
  217. formOpen.value = true;
  218. }
  219. function openEdit(record: AiModel) {
  220. editing.value = record;
  221. formOpen.value = true;
  222. }
  223. async function onSaved() {
  224. showMessage('已保存', 'success');
  225. await load();
  226. }
  227. /**
  228. * 设为默认:默认按类型隔离(对话 / 向量各一个),因此这里不再拦截向量模型。
  229. */
  230. async function onSetDefault(record: any, tab: ModelTab) {
  231. try {
  232. await modelApi.setDefaultModel(record.id);
  233. showMessage(`已将「${record.name || record.modelId}」设为默认${tab.label}`, 'success');
  234. await load();
  235. } catch (e: any) {
  236. showMessage(e?.message || '设置失败', 'error');
  237. }
  238. }
  239. /** 正在测试连通性的模型 id → loading(整体替换赋值保证响应式) */
  240. const testing = ref<Record<string, boolean>>({});
  241. /**
  242. * 测试连通性:服务端对已保存配置发起一次最小化真实调用。
  243. * 连通失败是预期结果,接口正常返回 success=false + 失败原因。
  244. */
  245. async function onTestConnection(record: AiModel) {
  246. testing.value = { ...testing.value, [record.id]: true };
  247. const name = record.name || record.modelId;
  248. try {
  249. const res = await modelApi.testModel(record.id);
  250. if (res.success) {
  251. showMessage(`「${name}」连接成功,耗时 ${res.latencyMs ?? 0}ms`, 'success');
  252. } else {
  253. showMessage(`「${name}」连接失败:${res.message || '未知原因'}`, 'error');
  254. }
  255. } catch (e: any) {
  256. showMessage(e?.message || `「${name}」测试请求失败`, 'error');
  257. } finally {
  258. testing.value = { ...testing.value, [record.id]: false };
  259. }
  260. }
  261. function onDelete(record: any) {
  262. const isEmbedding = record.type === 'embedding';
  263. Modal.confirm({
  264. title: '删除模型',
  265. content: `确认删除「${record.name || record.modelId}」?已绑定该模型的会话将回退到默认${
  266. isEmbedding ? '向量' : '对话'
  267. }模型。`,
  268. okText: '删除',
  269. okType: 'primary',
  270. okButtonProps: { danger: true },
  271. cancelText: '取消',
  272. onOk: async () => {
  273. await modelApi.deleteModel(record.id);
  274. showMessage('已删除', 'success');
  275. await load();
  276. },
  277. });
  278. }
  279. </script>
  280. <style lang="less" scoped>
  281. .ai-model {
  282. /* Tabs 撑起整页骨架:页签栏即顶部条,内容区自己滚动 */
  283. &__tabs {
  284. flex: 1 1 auto;
  285. min-height: 0;
  286. :deep(.ant-tabs-nav) {
  287. flex: 0 0 auto;
  288. margin: 0;
  289. padding: 0 var(--ai-sp-2);
  290. background: var(--ai-bg);
  291. border-bottom: 1px solid var(--ai-border);
  292. /* antd 自带的发丝线与顶部条边框重叠 */
  293. &::before {
  294. display: none;
  295. }
  296. }
  297. /* 靠页签内边距撑出顶部条高度(--ai-header-h),下划线才能压在分隔线上 */
  298. :deep(.ant-tabs-tab) {
  299. padding: 15px 0;
  300. font-size: 13.5px;
  301. }
  302. :deep(.ant-tabs-extra-content) {
  303. margin-left: var(--ai-sp-3);
  304. }
  305. /* 内容区内边距取分析页那一档(8px),左右与页签栏文字对齐 */
  306. :deep(.ant-tabs-content-holder) {
  307. flex: 1 1 auto;
  308. min-height: 0;
  309. padding: var(--ai-sp-2);
  310. overflow-y: auto;
  311. overscroll-behavior: contain;
  312. }
  313. }
  314. &__tab-count {
  315. display: inline-block;
  316. min-width: 18px;
  317. margin-left: var(--ai-sp-2);
  318. padding: 0 5px;
  319. font-size: 11px;
  320. line-height: 17px;
  321. text-align: center;
  322. color: var(--ai-text-2);
  323. background: var(--ai-bg-subtle);
  324. border: 1px solid var(--ai-border);
  325. border-radius: 999px;
  326. }
  327. /* 卡片网格:间距取分析页那一档(8px);圆角比页面外壳(12px)低一档,用 --ai-radius(8px) */
  328. &__grid {
  329. display: grid;
  330. grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  331. gap: var(--ai-sp-2);
  332. }
  333. &__card {
  334. display: flex;
  335. flex-direction: column;
  336. gap: var(--ai-sp-2);
  337. min-width: 0;
  338. padding: var(--ai-sp-3);
  339. background: var(--ai-bg);
  340. border: 1px solid var(--ai-border);
  341. border-radius: var(--ai-radius);
  342. transition:
  343. border-color 0.2s ease,
  344. box-shadow 0.2s ease,
  345. transform 0.2s ease;
  346. &:hover {
  347. border-color: var(--ai-border-strong);
  348. box-shadow: var(--ai-shadow);
  349. transform: translateY(-2px);
  350. }
  351. }
  352. &__card-head {
  353. display: flex;
  354. align-items: center;
  355. justify-content: space-between;
  356. gap: var(--ai-sp-3);
  357. min-width: 0;
  358. }
  359. &__card-meta {
  360. display: flex;
  361. align-items: center;
  362. gap: var(--ai-sp-2);
  363. min-width: 0;
  364. /* 一行里两个 mono:厂商 chip 不收缩,长的那段(Base URL)自己省略 */
  365. > span:last-child {
  366. flex: 1 1 auto;
  367. min-width: 0;
  368. }
  369. }
  370. &__empty {
  371. padding: var(--ai-sp-4) var(--ai-sp-3);
  372. font-size: 12.5px;
  373. line-height: 1.7;
  374. text-align: center;
  375. color: var(--ai-text-2);
  376. background-color: var(--ai-bg-subtle);
  377. border: 1px dashed var(--ai-border);
  378. border-radius: var(--ai-radius);
  379. }
  380. &__name {
  381. display: flex;
  382. align-items: center;
  383. gap: var(--ai-sp-2);
  384. min-width: 0;
  385. }
  386. &__name-text {
  387. font-size: 13.5px;
  388. font-weight: 600;
  389. color: var(--ai-text);
  390. }
  391. &__badge {
  392. flex: 0 0 auto;
  393. padding: 0 6px;
  394. font-size: 11px;
  395. line-height: 17px;
  396. white-space: nowrap;
  397. color: var(--ai-primary);
  398. background: var(--ai-primary-soft);
  399. border-radius: var(--ai-radius-sm);
  400. }
  401. &__sub {
  402. margin-top: 2px;
  403. font-family: 'SFMono-Regular', Consolas, monospace;
  404. font-size: 11.5px;
  405. color: var(--ai-text-2);
  406. }
  407. &__chip {
  408. display: inline-block;
  409. flex: 0 0 auto;
  410. padding: 1px 8px;
  411. font-size: 12px;
  412. line-height: 20px;
  413. white-space: nowrap;
  414. color: var(--ai-text);
  415. background: var(--ai-bg-subtle);
  416. border: 1px solid var(--ai-border);
  417. border-radius: 999px;
  418. }
  419. &__mono {
  420. font-family: 'SFMono-Regular', Consolas, monospace;
  421. font-size: 12px;
  422. color: var(--ai-text-2);
  423. }
  424. &__ellipsis {
  425. display: inline-block;
  426. max-width: 100%;
  427. overflow: hidden;
  428. white-space: nowrap;
  429. text-overflow: ellipsis;
  430. }
  431. &__status {
  432. flex: 0 0 auto;
  433. font-size: 12px;
  434. white-space: nowrap;
  435. &.is-on {
  436. color: @success-color;
  437. }
  438. &.is-warn {
  439. color: @warning-color;
  440. }
  441. &.is-off {
  442. color: var(--ai-text-2);
  443. }
  444. }
  445. &__card-ops {
  446. display: flex;
  447. align-items: center;
  448. gap: var(--ai-sp-1);
  449. margin-top: auto;
  450. padding-top: var(--ai-sp-2);
  451. border-top: 1px solid var(--ai-border);
  452. }
  453. }
  454. /* ======================================================================
  455. * 卡片化改版:对齐自主模式「持续联系」页与插件与技能页的视觉规范
  456. * 页面底 #f4f6fa · 白卡圆角 14 · 描边 #e7ebf3 · 标题蓝条 · 主按钮 #2a50ec
  457. * ==================================================================== */
  458. .ai-page.ai-model {
  459. gap: 8px;
  460. width: 100%;
  461. height: 100%;
  462. min-height: 0;
  463. margin: 0;
  464. padding: 8px 10px;
  465. background: #f4f6fa;
  466. border: 0;
  467. border-radius: 0;
  468. box-sizing: border-box;
  469. overflow: hidden;
  470. }
  471. .ai-model {
  472. &__toolbar {
  473. display: flex;
  474. flex: 0 0 auto;
  475. align-items: center;
  476. gap: 12px;
  477. min-height: 52px;
  478. padding: 10px 14px;
  479. background: #fff;
  480. border: 1px solid #e7ebf3;
  481. border-radius: 14px;
  482. box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
  483. box-sizing: border-box;
  484. }
  485. &__card-title {
  486. position: relative;
  487. flex: 0 0 auto;
  488. padding-left: 11px;
  489. color: #111827;
  490. font-size: 15px;
  491. font-weight: 600;
  492. line-height: 22px;
  493. white-space: nowrap;
  494. &::before {
  495. content: '';
  496. position: absolute;
  497. top: 4px;
  498. bottom: 4px;
  499. left: 0;
  500. width: 3px;
  501. background: #2a50ec;
  502. border-radius: 2px;
  503. }
  504. }
  505. &__tabs {
  506. display: inline-flex;
  507. flex: 0 0 auto;
  508. align-items: center;
  509. gap: 2px;
  510. padding: 3px;
  511. background: #f4f6fa;
  512. border: 1px solid #e7ebf3;
  513. border-radius: 10px;
  514. }
  515. &__tab {
  516. display: inline-flex;
  517. align-items: center;
  518. gap: 6px;
  519. height: 28px;
  520. padding: 0 10px;
  521. color: #5b6577;
  522. font-size: 12.5px;
  523. font-weight: 500;
  524. font-family: inherit;
  525. line-height: 1;
  526. white-space: nowrap;
  527. background: transparent;
  528. border: 0;
  529. border-radius: 8px;
  530. cursor: pointer;
  531. transition:
  532. color 0.15s ease,
  533. background-color 0.15s ease,
  534. box-shadow 0.15s ease;
  535. &:hover {
  536. color: #2a50ec;
  537. background: rgb(42 80 236 / 6%);
  538. }
  539. &.is-on {
  540. color: #2a50ec;
  541. font-weight: 600;
  542. background: #fff;
  543. box-shadow: 0 1px 3px rgb(16 24 40 / 10%);
  544. }
  545. }
  546. &__tab-count {
  547. display: inline-block;
  548. min-width: 18px;
  549. margin-left: 0;
  550. padding: 0 5px;
  551. color: inherit;
  552. font-size: 11px;
  553. line-height: 16px;
  554. text-align: center;
  555. background: rgb(42 80 236 / 8%);
  556. border: 0;
  557. border-radius: 999px;
  558. }
  559. &__summary {
  560. max-width: 220px;
  561. overflow: hidden;
  562. color: #98a2b3;
  563. font-size: 11.5px;
  564. white-space: nowrap;
  565. text-overflow: ellipsis;
  566. }
  567. &__spacer {
  568. flex: 1 1 auto;
  569. }
  570. &__actions {
  571. display: flex;
  572. flex: 0 0 auto;
  573. align-items: center;
  574. gap: 8px;
  575. :deep(.ant-btn) {
  576. height: 30px;
  577. padding: 0 14px;
  578. color: #5b6577;
  579. font-size: 12px;
  580. font-weight: 500;
  581. background: #fff;
  582. border: 1px solid #e7ebf3;
  583. border-radius: 8px;
  584. box-shadow: none;
  585. &:hover,
  586. &:focus {
  587. color: #2a50ec;
  588. background: #f8faff;
  589. border-color: #bfcdfb;
  590. }
  591. }
  592. :deep(.ant-btn-primary) {
  593. color: #fff;
  594. background: #2a50ec;
  595. border-color: #2a50ec;
  596. &:hover,
  597. &:focus {
  598. color: #fff;
  599. background: #4b6bf0;
  600. border-color: #4b6bf0;
  601. }
  602. &:active {
  603. background: #1e3ed0;
  604. border-color: #1e3ed0;
  605. }
  606. }
  607. :deep(.ant-btn[disabled]) {
  608. color: rgb(0 0 0 / 25%);
  609. background: #f5f5f5;
  610. border-color: #e7ebf3;
  611. }
  612. }
  613. &__panel {
  614. display: flex;
  615. flex: 1 1 auto;
  616. flex-direction: column;
  617. min-height: 0;
  618. background: #fff;
  619. border: 1px solid #e7ebf3;
  620. border-radius: 14px;
  621. box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
  622. overflow: hidden;
  623. }
  624. &__panel-head {
  625. display: flex;
  626. flex: 0 0 auto;
  627. align-items: center;
  628. gap: 10px;
  629. min-height: 48px;
  630. padding: 8px 14px;
  631. border-bottom: 1px solid #eff2f7;
  632. box-sizing: border-box;
  633. }
  634. &__panel-desc {
  635. min-width: 0;
  636. overflow: hidden;
  637. color: #98a2b3;
  638. font-size: 11.5px;
  639. white-space: nowrap;
  640. text-overflow: ellipsis;
  641. }
  642. &__panel-count {
  643. color: #98a2b3;
  644. font-size: 11.5px;
  645. white-space: nowrap;
  646. font-variant-numeric: tabular-nums;
  647. }
  648. &__panel-body {
  649. flex: 1 1 auto;
  650. min-height: 0;
  651. padding: 14px;
  652. overflow-y: auto;
  653. overscroll-behavior: contain;
  654. box-sizing: border-box;
  655. }
  656. &__grid {
  657. display: grid;
  658. grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  659. gap: 12px;
  660. }
  661. &__card {
  662. gap: 10px;
  663. padding: 14px;
  664. background: #fff;
  665. border: 1px solid #e7ebf3;
  666. border-radius: 12px;
  667. box-sizing: border-box;
  668. transition:
  669. border-color 0.15s ease,
  670. box-shadow 0.15s ease;
  671. &:hover {
  672. border-color: #bfcdfb;
  673. box-shadow: 0 4px 14px rgb(16 24 40 / 8%);
  674. transform: none;
  675. }
  676. }
  677. &__card-head {
  678. align-items: flex-start;
  679. justify-content: flex-start;
  680. gap: 10px;
  681. }
  682. &__card-icon {
  683. display: inline-flex;
  684. flex: 0 0 auto;
  685. align-items: center;
  686. justify-content: center;
  687. width: 34px;
  688. height: 34px;
  689. color: #2a50ec;
  690. background: rgb(42 80 236 / 8%);
  691. border-radius: 9px;
  692. &.is-embedding {
  693. color: #7c3aed;
  694. background: rgb(124 58 237 / 8%);
  695. }
  696. }
  697. &__card-heading {
  698. display: flex;
  699. flex: 1 1 auto;
  700. flex-direction: column;
  701. gap: 4px;
  702. min-width: 0;
  703. }
  704. &__name-row {
  705. display: flex;
  706. align-items: center;
  707. gap: 6px;
  708. min-width: 0;
  709. }
  710. &__name {
  711. display: block;
  712. min-width: 0;
  713. overflow: hidden;
  714. color: #111827;
  715. font-size: 14px;
  716. font-weight: 600;
  717. white-space: nowrap;
  718. text-overflow: ellipsis;
  719. }
  720. &__card-sub {
  721. overflow: hidden;
  722. color: #98a2b3;
  723. font-size: 11.5px;
  724. font-family: inherit;
  725. white-space: nowrap;
  726. text-overflow: ellipsis;
  727. font-variant-numeric: tabular-nums;
  728. }
  729. &__chip {
  730. display: inline-flex;
  731. align-items: center;
  732. flex: 0 0 auto;
  733. max-width: 220px;
  734. height: 20px;
  735. padding: 0 7px;
  736. overflow: hidden;
  737. color: #5b6577;
  738. font-size: 11px;
  739. font-weight: 500;
  740. line-height: 18px;
  741. white-space: nowrap;
  742. text-overflow: ellipsis;
  743. background: #f4f6fa;
  744. border: 1px solid #e7ebf3;
  745. border-radius: 6px;
  746. &.is-primary {
  747. color: #2a50ec;
  748. background: rgb(42 80 236 / 7%);
  749. border-color: rgb(42 80 236 / 18%);
  750. }
  751. }
  752. &__status {
  753. display: inline-flex;
  754. flex: 0 0 auto;
  755. align-items: center;
  756. gap: 5px;
  757. height: 22px;
  758. padding: 0 8px;
  759. color: #5b6577;
  760. font-size: 11px;
  761. font-weight: 500;
  762. white-space: nowrap;
  763. background: #f4f6fa;
  764. border: 1px solid #e7ebf3;
  765. border-radius: 6px;
  766. &::before {
  767. content: '';
  768. width: 6px;
  769. height: 6px;
  770. background: currentColor;
  771. border-radius: 50%;
  772. }
  773. &.is-on {
  774. color: #12805c;
  775. background: rgb(18 128 92 / 8%);
  776. border-color: rgb(18 128 92 / 20%);
  777. }
  778. &.is-warn {
  779. color: #b7791f;
  780. background: rgb(183 121 31 / 8%);
  781. border-color: rgb(183 121 31 / 20%);
  782. }
  783. &.is-off {
  784. color: #5b6577;
  785. background: #f4f6fa;
  786. border-color: #e7ebf3;
  787. }
  788. }
  789. &__card-meta {
  790. gap: 8px;
  791. min-width: 0;
  792. }
  793. &__meta-label {
  794. flex: 0 0 48px;
  795. color: #98a2b3;
  796. font-size: 11.5px;
  797. white-space: nowrap;
  798. }
  799. &__mono {
  800. min-width: 0;
  801. color: #5b6577;
  802. font-size: 11.5px;
  803. }
  804. &__card-ops {
  805. display: flex;
  806. align-items: center;
  807. gap: 6px;
  808. margin-top: auto;
  809. padding-top: 10px;
  810. border-top: 1px solid #eff2f7;
  811. }
  812. &__ops-sp {
  813. flex: 1 1 auto;
  814. }
  815. &__op {
  816. display: inline-flex;
  817. align-items: center;
  818. justify-content: center;
  819. gap: 4px;
  820. height: 26px;
  821. padding: 0 10px;
  822. color: #5b6577;
  823. font-size: 11.5px;
  824. font-weight: 500;
  825. font-family: inherit;
  826. white-space: nowrap;
  827. background: #fff;
  828. border: 1px solid #e7ebf3;
  829. border-radius: 6px;
  830. cursor: pointer;
  831. transition:
  832. color 0.15s ease,
  833. background-color 0.15s ease,
  834. border-color 0.15s ease;
  835. &:hover:not(:disabled) {
  836. color: #2a50ec;
  837. background: #f8faff;
  838. border-color: #bfcdfb;
  839. }
  840. &:disabled {
  841. cursor: not-allowed;
  842. opacity: 0.55;
  843. }
  844. &.is-danger:hover:not(:disabled) {
  845. color: #e5484d;
  846. background: #fff5f5;
  847. border-color: rgb(229 72 77 / 35%);
  848. }
  849. }
  850. }
  851. @media (max-width: 1440px) {
  852. .ai-model__summary {
  853. display: none;
  854. }
  855. }
  856. @media (max-width: 1180px) {
  857. .ai-model__panel-desc {
  858. display: none;
  859. }
  860. }
  861. @media (max-width: 1120px) {
  862. .ai-model__tab .jeesite-icon {
  863. display: none !important;
  864. }
  865. }
  866. </style>
  867. <style lang="less">
  868. /* 新增/编辑模型弹框:与页面卡片统一的圆角、控件尺寸与按钮规范 */
  869. /*
  870. * 项目全局对 .ant-modal 有过高优先级覆盖(:not(...) 链),这里必须用 !important
  871. * 才能把圆角与头部配色统一到页面卡片规范。
  872. */
  873. .ai-model-modal .ant-modal-content {
  874. overflow: hidden;
  875. border-radius: 14px !important;
  876. padding: 0 !important;
  877. }
  878. .ai-model-modal .ant-modal-header {
  879. margin: 0 !important;
  880. padding: 14px 20px !important;
  881. background: #fff !important;
  882. border-bottom: 1px solid #eff2f7 !important;
  883. border-radius: 14px 14px 0 0 !important;
  884. }
  885. .ai-model-modal .ant-modal-title {
  886. color: #111827 !important;
  887. font-size: 15px !important;
  888. font-weight: 600 !important;
  889. }
  890. .ai-model-modal .ant-modal-close {
  891. top: 14px;
  892. right: 16px;
  893. }
  894. .ai-model-modal .ant-modal-body {
  895. padding: 16px 20px 8px !important;
  896. }
  897. .ai-model-modal .ant-form-item-label > label {
  898. color: #5b6577;
  899. font-size: 12px;
  900. }
  901. .ai-model-modal .ant-input,
  902. .ai-model-modal .ant-input-affix-wrapper,
  903. .ai-model-modal .ant-select-selector {
  904. min-height: 32px;
  905. border-color: #e7ebf3 !important;
  906. border-radius: 8px !important;
  907. box-shadow: none;
  908. }
  909. .ai-model-modal .ant-select-selector {
  910. padding: 0 10px !important;
  911. }
  912. .ai-model-modal .ant-input:focus,
  913. .ai-model-modal .ant-input-affix-wrapper-focused,
  914. .ai-model-modal .ant-select-focused .ant-select-selector {
  915. border-color: #2a50ec !important;
  916. box-shadow: 0 0 0 2px rgb(42 80 236 / 12%) !important;
  917. }
  918. </style>